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,137 @@
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.GetMedia;
using TeleWave.Application.Media.ListMedia;
using TeleWave.Application.Media.Register;
using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Identity;
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<UploadMediaResponse>(StatusCodes.Status201Created);
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
admin.MapGet("/{id:guid}", Get).Produces<MediaAssetDto>();
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
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)
{
storage.DeleteUpload(token);
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,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ListMediaAssetsQuery(
page <= 0 ? 1 : page,
pageSize <= 0 ? 20 : pageSize,
status,
search
),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> Get(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetMediaAssetQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Delete(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UploadMediaResponse(Guid Id);
+8
View File
@@ -13,6 +13,13 @@ using Serilog;
var builder = WebApplication.CreateBuilder(args);
// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки
// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints.
builder.WebHost.ConfigureKestrel(options =>
options.Limits.MaxRequestBodySize =
builder.Configuration.GetValue<long?>("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024
);
// Структурное логирование (Serilog), конфигурация из appsettings/env.
builder.Services.AddSerilog(
(services, configuration) =>
@@ -105,6 +112,7 @@ app.MapHealthChecks("/health");
app.MapAuthEndpoints();
app.MapRoleEndpoints();
app.MapAdminUserEndpoints();
app.MapMediaEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
@@ -8,5 +8,8 @@
"AdminSeed": {
"Username": "admin",
"Password": "Passw0rd!Dev"
},
"Storage": {
"RootPath": ".dev-media"
}
}
+13
View File
@@ -13,6 +13,19 @@
"Username": "",
"Password": ""
},
"Storage": {
"RootPath": "/media",
"SegmentSeconds": 2,
"KeepOriginals": false,
"MinFreeSpaceBytes": 10737418240
},
"Media": {
"FfmpegPath": "ffmpeg",
"FfprobePath": "ffprobe",
"MaxUploadBytes": 21474836480,
"TranscodeThreads": 3,
"InboxScanSeconds": 15
},
"Serilog": {
"Using": [ "Serilog.Sinks.Console" ],
"MinimumLevel": {
@@ -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("Неподдерживаемое расширение файла.");
}
}
@@ -0,0 +1,105 @@
namespace TeleWave.Domain.Media;
/// <summary>
/// Один видеофайл в библиотеке. После загрузки нормализуется ffmpeg'ом в HLS-сегменты по
/// <see cref="SegmentSeconds"/> секунд; длительность добивается до кратности длине сегмента, чтобы
/// эфирная математика (следующий этап) оставалась целочисленной. Rich model: приватные сеттеры,
/// фабрика <see cref="Register"/> и поведенческие переходы статуса.
/// </summary>
public class MediaAsset
{
public Guid Id { get; private set; }
/// <summary>Исходное имя файла (для отображения в админке).</summary>
public string OriginalFileName { get; private set; } = string.Empty;
/// <summary>Расширение исходника с точкой, в нижнем регистре (например «.mkv»).</summary>
public string OriginalExtension { get; private set; } = string.Empty;
public MediaSource Source { get; private set; }
public MediaAssetStatus Status { get; private set; }
/// <summary>Точная длительность нарезанного ассета (кратна <see cref="SegmentSeconds"/>).</summary>
public TimeSpan? Duration { get; private set; }
public int? SegmentSeconds { get; private set; }
public int? SegmentCount { get; private set; }
public int? Width { get; private set; }
public int? Height { get; private set; }
public string? VideoCodec { get; private set; }
public string? AudioCodec { get; private set; }
/// <summary>Относительный путь каталога сегментов от корня хранилища («assets/{id}»).</summary>
public string? RelativePath { get; private set; }
/// <summary>Текст ошибки, если <see cref="Status"/> == <see cref="MediaAssetStatus.Failed"/>.</summary>
public string? ErrorMessage { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset UpdatedAt { get; private set; }
private MediaAsset() { }
public static MediaAsset Register(string originalFileName, string extension, MediaSource source)
{
var now = DateTimeOffset.UtcNow;
return new MediaAsset
{
Id = Guid.NewGuid(),
OriginalFileName = originalFileName,
OriginalExtension = extension.ToLowerInvariant(),
Source = source,
Status = MediaAssetStatus.Pending,
CreatedAt = now,
UpdatedAt = now,
};
}
public void MarkProcessing()
{
Status = MediaAssetStatus.Processing;
ErrorMessage = null;
Touch();
}
public void MarkReady(
TimeSpan duration,
int segmentSeconds,
int segmentCount,
int width,
int height,
string videoCodec,
string audioCodec,
string relativePath
)
{
Status = MediaAssetStatus.Ready;
Duration = duration;
SegmentSeconds = segmentSeconds;
SegmentCount = segmentCount;
Width = width;
Height = height;
VideoCodec = videoCodec;
AudioCodec = audioCodec;
RelativePath = relativePath;
ErrorMessage = null;
Touch();
}
public void MarkFailed(string error)
{
Status = MediaAssetStatus.Failed;
ErrorMessage = error;
Touch();
}
/// <summary>Сбросить прерванную обработку (Processing после рестарта) обратно в очередь.</summary>
public void ResetToPending()
{
Status = MediaAssetStatus.Pending;
ErrorMessage = null;
Touch();
}
private void Touch() => UpdatedAt = DateTimeOffset.UtcNow;
}
@@ -0,0 +1,17 @@
namespace TeleWave.Domain.Media;
/// <summary>Жизненный цикл медиа-ассета от загрузки до готовности к эфиру.</summary>
public enum MediaAssetStatus
{
/// <summary>Файл загружен, ждёт обработки в очереди.</summary>
Pending,
/// <summary>ffmpeg нарезает файл на HLS-сегменты.</summary>
Processing,
/// <summary>Нарезка завершена, ассет готов к постановке в эфир.</summary>
Ready,
/// <summary>Обработка провалилась (см. <see cref="MediaAsset.ErrorMessage"/>).</summary>
Failed,
}
@@ -0,0 +1,11 @@
namespace TeleWave.Domain.Media;
/// <summary>Откуда файл попал в хранилище.</summary>
public enum MediaSource
{
/// <summary>Загружен через админку (chunked/stream upload в uploads/).</summary>
Upload,
/// <summary>Положен вручную в inbox/ и подобран сканером.</summary>
Inbox,
}
@@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Persistence;
namespace TeleWave.Infrastructure;
@@ -85,6 +86,23 @@ public static class DependencyInjection
services.AddScoped<ICurrentUser, CurrentUser>();
services.AddScoped<DbInitializer>();
AddMedia(services, configuration);
return services;
}
/// <summary>Хранилище медиа, обработка ffmpeg, очередь и фоновые сервисы (очередь + inbox-сканер).</summary>
private static void AddMedia(IServiceCollection services, IConfiguration configuration)
{
services.Configure<StorageOptions>(configuration.GetSection(StorageOptions.SectionName));
services.Configure<MediaOptions>(configuration.GetSection(MediaOptions.SectionName));
services.AddSingleton<MediaPathResolver>();
services.AddSingleton<IMediaStorage, FileSystemMediaStorage>();
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
services.AddHostedService<MediaProcessingBackgroundService>();
services.AddHostedService<InboxScannerBackgroundService>();
}
}
@@ -0,0 +1,238 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Media;
/// <summary>
/// Нормализация (H.264/AAC 1080p) и нарезка исходника на HLS-сегменты через ffmpeg. Длительность
/// добивается чёрным кадром + тишиной до кратности длине сегмента — ключевой инвариант эфирной
/// математики (см. docs/media-storage-and-streaming.md).
/// </summary>
public sealed class FfmpegMediaProcessor(
MediaPathResolver paths,
IOptions<StorageOptions> storageOptions,
IOptions<MediaOptions> mediaOptions
) : IMediaProcessor
{
private const int MaxWidth = 1920;
private readonly StorageOptions _storage = storageOptions.Value;
private readonly MediaOptions _media = mediaOptions.Value;
public async Task<MediaProcessingResult> ProcessAsync(
Guid assetId,
string extension,
CancellationToken cancellationToken
)
{
var originalPath = paths.OriginalPath(assetId, extension);
if (!File.Exists(originalPath))
throw new FileNotFoundException("Исходник для обработки не найден.", originalPath);
var probe = await ProbeAsync(originalPath, cancellationToken);
var segmentSeconds = _storage.SegmentSeconds;
// Целевая длительность — вверх до кратности длине сегмента.
var target = Math.Ceiling(probe.Duration / segmentSeconds) * segmentSeconds;
var pad = target - probe.Duration;
var assetDir = paths.AssetDir(assetId);
if (Directory.Exists(assetDir))
Directory.Delete(assetDir, recursive: true);
Directory.CreateDirectory(assetDir);
var (outWidth, outHeight) = ScaleDown(probe.Width, probe.Height);
var args = BuildFfmpegArgs(originalPath, assetDir, segmentSeconds, target, pad);
var result = await ProcessRunner.RunAsync(
_media.FfmpegPath,
args,
lowPriority: true,
cancellationToken
);
if (result.ExitCode != 0)
throw new InvalidOperationException(
$"ffmpeg завершился с кодом {result.ExitCode}: {Tail(result.StdErr)}"
);
var playlist = Path.Combine(assetDir, "index.m3u8");
if (!File.Exists(playlist))
throw new InvalidOperationException("ffmpeg не создал плейлист index.m3u8.");
var segmentCount = Directory.GetFiles(assetDir, "seg*.ts").Length;
if (segmentCount == 0)
throw new InvalidOperationException("ffmpeg не создал ни одного сегмента.");
return new MediaProcessingResult(
TimeSpan.FromSeconds(target),
segmentSeconds,
segmentCount,
outWidth,
outHeight,
"h264",
"aac",
paths.AssetRelativePath(assetId)
);
}
private List<string> BuildFfmpegArgs(
string input,
string assetDir,
int segmentSeconds,
double target,
double pad
)
{
var padding = pad > 0.001;
var vfilter = $"scale='min({MaxWidth},iw)':-2";
if (padding)
vfilter += $",tpad=stop_duration={Fmt(pad)}:stop_mode=add:color=black";
var args = new List<string>
{
"-hide_banner",
"-nostdin",
"-y",
"-i",
input,
"-threads",
_media.TranscodeThreads.ToString(CultureInfo.InvariantCulture),
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"21",
"-maxrate",
"4500k",
"-bufsize",
"9000k",
"-vf",
vfilter,
"-force_key_frames",
$"expr:gte(t,n_forced*{segmentSeconds.ToString(CultureInfo.InvariantCulture)})",
"-sc_threshold",
"0",
"-c:a",
"aac",
"-b:a",
"128k",
"-ac",
"2",
"-ar",
"48000",
};
if (padding)
{
args.Add("-af");
args.Add($"apad=pad_dur={Fmt(pad)}");
args.Add("-t");
args.Add(Fmt(target));
}
args.AddRange(
[
"-f",
"hls",
"-hls_time",
segmentSeconds.ToString(CultureInfo.InvariantCulture),
"-hls_playlist_type",
"vod",
"-hls_list_size",
"0",
"-hls_segment_filename",
Path.Combine(assetDir, "seg%05d.ts"),
Path.Combine(assetDir, "index.m3u8"),
]
);
return args;
}
private async Task<ProbeInfo> ProbeAsync(string path, CancellationToken cancellationToken)
{
var result = await ProcessRunner.RunAsync(
_media.FfprobePath,
[
"-v",
"quiet",
"-print_format",
"json",
"-show_format",
"-show_streams",
path,
],
lowPriority: false,
cancellationToken
);
if (result.ExitCode != 0)
throw new InvalidOperationException(
$"ffprobe завершился с кодом {result.ExitCode}: {Tail(result.StdErr)}"
);
using var doc = JsonDocument.Parse(result.StdOut);
var root = doc.RootElement;
double duration = 0;
if (
root.TryGetProperty("format", out var format)
&& format.TryGetProperty("duration", out var durEl)
&& double.TryParse(
durEl.GetString(),
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var parsed
)
)
duration = parsed;
int width = 0,
height = 0;
var hasVideo = false;
if (root.TryGetProperty("streams", out var streams))
{
foreach (var stream in streams.EnumerateArray())
{
if (!stream.TryGetProperty("codec_type", out var typeEl))
continue;
if (typeEl.GetString() == "video" && !hasVideo)
{
hasVideo = true;
width = stream.TryGetProperty("width", out var w) ? w.GetInt32() : 0;
height = stream.TryGetProperty("height", out var h) ? h.GetInt32() : 0;
}
}
}
if (duration <= 0)
throw new InvalidOperationException("Не удалось определить длительность файла.");
if (!hasVideo || width <= 0 || height <= 0)
throw new InvalidOperationException("В файле не найдена видеодорожка.");
return new ProbeInfo(duration, width, height);
}
private static (int Width, int Height) ScaleDown(int width, int height)
{
if (width <= MaxWidth)
return (width, height);
var scaledHeight = (int)Math.Round((double)height * MaxWidth / width);
if (scaledHeight % 2 != 0)
scaledHeight++;
return (MaxWidth, scaledHeight);
}
private static string Fmt(double value) =>
value.ToString("0.###", CultureInfo.InvariantCulture);
private static string Tail(string text)
{
text = text.Trim();
const int max = 500;
return text.Length <= max ? text : text[^max..];
}
private sealed record ProbeInfo(double Duration, int Width, int Height);
}
@@ -0,0 +1,84 @@
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Media;
namespace TeleWave.Infrastructure.Media;
/// <summary>Файловая реализация <see cref="IMediaStorage"/> поверх <see cref="MediaPathResolver"/>.</summary>
public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStorage
{
private const int CopyBufferSize = 1024 * 1024;
public long GetAvailableFreeSpaceBytes()
{
try
{
return new DriveInfo(paths.AssetsDir).AvailableFreeSpace;
}
catch (Exception ex) when (ex is ArgumentException or IOException)
{
// Не блокируем загрузку, если ФС не отдаёт метрику (например экзотическая точка монтирования).
return long.MaxValue;
}
}
public async Task<string> SaveUploadAsync(
Stream content,
string extension,
CancellationToken cancellationToken
)
{
Directory.CreateDirectory(paths.UploadsDir);
var token = Guid.NewGuid().ToString("N") + extension.ToLowerInvariant();
var path = paths.UploadPath(token);
await using var file = new FileStream(
path,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
CopyBufferSize,
useAsync: true
);
await content.CopyToAsync(file, CopyBufferSize, cancellationToken);
return token;
}
public void DeleteUpload(string uploadToken)
{
var path = paths.UploadPath(uploadToken);
if (File.Exists(path))
File.Delete(path);
}
public Task PromoteToOriginalAsync(
MediaSource source,
string sourceToken,
Guid assetId,
string extension,
CancellationToken cancellationToken
)
{
var sourcePath = source == MediaSource.Inbox
? paths.InboxPath(sourceToken)
: paths.UploadPath(sourceToken);
if (!File.Exists(sourcePath))
throw new FileNotFoundException("Исходный файл не найден в хранилище.", sourcePath);
Directory.CreateDirectory(paths.OriginalsDir);
var destination = paths.OriginalPath(assetId, extension);
File.Move(sourcePath, destination, overwrite: true);
return Task.CompletedTask;
}
public void DeleteAssetArtifacts(Guid assetId, string extension)
{
var original = paths.OriginalPath(assetId, extension);
if (File.Exists(original))
File.Delete(original);
var assetDir = paths.AssetDir(assetId);
if (Directory.Exists(assetDir))
Directory.Delete(assetDir, recursive: true);
}
}
@@ -0,0 +1,118 @@
using LiteCqrs;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Media;
using TeleWave.Application.Media.Register;
using TeleWave.Domain.Media;
namespace TeleWave.Infrastructure.Media;
/// <summary>
/// Периодически сканирует inbox/: файлы с допустимым расширением и стабильным размером (не растут
/// между тиками — значит докопировались) регистрируются как ассеты и уходят в очередь обработки.
/// Регистрация переносит файл в originals/, поэтому повторно он не подхватывается.
/// </summary>
public sealed class InboxScannerBackgroundService(
IServiceScopeFactory scopeFactory,
IMediaProcessingQueue queue,
MediaPathResolver paths,
IOptions<MediaOptions> mediaOptions,
ILogger<InboxScannerBackgroundService> logger
) : BackgroundService
{
private readonly MediaOptions _media = mediaOptions.Value;
private readonly Dictionary<string, long> _lastSizes = new(StringComparer.Ordinal);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
paths.EnsureDirectories();
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Math.Max(1, _media.InboxScanSeconds)));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
await ScanAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка сканирования inbox");
}
}
}
private async Task ScanAsync(CancellationToken cancellationToken)
{
if (!Directory.Exists(paths.InboxDir))
return;
var files = Directory.EnumerateFiles(paths.InboxDir)
.Where(f => MediaFormats.IsAllowed(f))
.ToList();
var present = new HashSet<string>(StringComparer.Ordinal);
foreach (var path in files)
{
var name = Path.GetFileName(path);
present.Add(name);
long size;
try
{
size = new FileInfo(path).Length;
}
catch (IOException)
{
continue; // файл ещё пишется/заблокирован — попробуем на следующем тике
}
if (size <= 0)
{
_lastSizes[name] = size;
continue;
}
if (!_lastSizes.TryGetValue(name, out var previous) || previous != size)
{
_lastSizes[name] = size; // размер меняется — ждём стабилизации
continue;
}
await RegisterAsync(name, cancellationToken);
_lastSizes.Remove(name);
}
// Забываем исчезнувшие файлы, чтобы словарь не рос.
foreach (var stale in _lastSizes.Keys.Where(k => !present.Contains(k)).ToList())
_lastSizes.Remove(stale);
}
private async Task RegisterAsync(string fileName, CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var sender = scope.ServiceProvider.GetRequiredService<ISender>();
var result = await sender.Send(
new RegisterMediaAssetCommand(fileName, MediaSource.Inbox, fileName),
cancellationToken
);
if (result.IsSuccess)
{
queue.Enqueue(result.Value);
logger.LogInformation("Из inbox зарегистрирован ассет {AssetId} ({File})", result.Value, fileName);
}
else
{
logger.LogWarning("Не удалось зарегистрировать {File} из inbox: {Error}", fileName, result.Error.Code);
}
}
}
@@ -0,0 +1,18 @@
namespace TeleWave.Infrastructure.Media;
public sealed class MediaOptions
{
public const string SectionName = "Media";
public string FfmpegPath { get; init; } = "ffmpeg";
public string FfprobePath { get; init; } = "ffprobe";
/// <summary>Максимальный размер загружаемого файла, байт (по умолчанию 20 ГБ).</summary>
public long MaxUploadBytes { get; init; } = 20L * 1024 * 1024 * 1024;
/// <summary>Число потоков ffmpeg — оставляем ядро API и раздаче (см. docs, 4 vCPU).</summary>
public int TranscodeThreads { get; init; } = 3;
/// <summary>Период опроса inbox/ сканером, секунды.</summary>
public int InboxScanSeconds { get; init; } = 15;
}
@@ -0,0 +1,70 @@
using Microsoft.Extensions.Options;
namespace TeleWave.Infrastructure.Media;
/// <summary>
/// Единая точка резолва путей хранилища + защита от path traversal. Любой путь, собранный из
/// внешних данных (имя загруженного файла, имя из inbox/), проверяется на нахождение внутри корня.
/// </summary>
public sealed class MediaPathResolver
{
private readonly string _root;
public MediaPathResolver(IOptions<StorageOptions> options)
{
_root = Path.GetFullPath(options.Value.RootPath);
InboxDir = Path.Combine(_root, "inbox");
UploadsDir = Path.Combine(_root, "uploads");
OriginalsDir = Path.Combine(_root, "originals");
AssetsDir = Path.Combine(_root, "assets");
}
public string InboxDir { get; }
public string UploadsDir { get; }
public string OriginalsDir { get; }
public string AssetsDir { get; }
public void EnsureDirectories()
{
Directory.CreateDirectory(InboxDir);
Directory.CreateDirectory(UploadsDir);
Directory.CreateDirectory(OriginalsDir);
Directory.CreateDirectory(AssetsDir);
}
public string OriginalPath(Guid assetId, string extension) =>
EnsureWithinRoot(Path.Combine(OriginalsDir, assetId.ToString("N") + extension));
public string AssetDir(Guid assetId) =>
EnsureWithinRoot(Path.Combine(AssetsDir, assetId.ToString("N")));
public string AssetRelativePath(Guid assetId) => $"assets/{assetId:N}";
/// <summary>Резолвит имя файла внутри uploads/ (токен загрузки), проверяя выход за пределы каталога.</summary>
public string UploadPath(string token) =>
EnsureWithin(UploadsDir, Path.Combine(UploadsDir, token));
/// <summary>Резолвит имя файла внутри inbox/, проверяя выход за пределы каталога.</summary>
public string InboxPath(string fileName) =>
EnsureWithin(InboxDir, Path.Combine(InboxDir, fileName));
private string EnsureWithinRoot(string candidate) => EnsureWithin(_root, candidate);
private static string EnsureWithin(string baseDir, string candidate)
{
var full = Path.GetFullPath(candidate);
var normalizedBase = baseDir.EndsWith(Path.DirectorySeparatorChar)
? baseDir
: baseDir + Path.DirectorySeparatorChar;
if (
!full.StartsWith(normalizedBase, StringComparison.Ordinal)
&& !string.Equals(full, baseDir, StringComparison.Ordinal)
)
throw new UnauthorizedAccessException(
$"Путь '{candidate}' выходит за пределы каталога хранилища."
);
return full;
}
}
@@ -0,0 +1,163 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Media;
namespace TeleWave.Infrastructure.Media;
/// <summary>
/// Единственный потребитель очереди обработки: по одному ассету за раз прогоняет через ffmpeg.
/// На старте восстанавливает прерванные задачи (Pending/Processing) — переживает рестарт/краш.
/// БД-контекст держится короткими отрезками (пометить статус), сам транскод идёт вне scope, чтобы
/// не держать соединение открытым минутами.
/// </summary>
public sealed class MediaProcessingBackgroundService(
IMediaProcessingQueue queue,
IServiceScopeFactory scopeFactory,
MediaPathResolver paths,
IMediaProcessor processor,
IOptions<StorageOptions> storageOptions,
ILogger<MediaProcessingBackgroundService> logger
) : BackgroundService
{
private readonly StorageOptions _storage = storageOptions.Value;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
paths.EnsureDirectories();
await RecoverPendingAsync(stoppingToken);
await foreach (var assetId in queue.DequeueAllAsync(stoppingToken))
{
try
{
await ProcessAsync(assetId, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Необработанная ошибка обработки ассета {AssetId}", assetId);
}
}
}
private async Task RecoverPendingAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var pending = await db.MediaAssets
.Where(x =>
x.Status == MediaAssetStatus.Pending || x.Status == MediaAssetStatus.Processing
)
.ToListAsync(cancellationToken);
foreach (var asset in pending.Where(x => x.Status == MediaAssetStatus.Processing))
asset.ResetToPending();
if (pending.Count > 0)
await db.SaveChangesAsync(cancellationToken);
foreach (var asset in pending)
queue.Enqueue(asset.Id);
}
private async Task ProcessAsync(Guid assetId, CancellationToken cancellationToken)
{
var extension = await BeginProcessingAsync(assetId, cancellationToken);
if (extension is null)
return;
try
{
var result = await processor.ProcessAsync(assetId, extension, cancellationToken);
await CompleteAsync(assetId, result, cancellationToken);
if (!_storage.KeepOriginals)
DeleteOriginal(assetId, extension);
logger.LogInformation(
"Ассет {AssetId} обработан: {Segments} сегментов, {Seconds:0.#}с",
assetId,
result.SegmentCount,
result.Duration.TotalSeconds
);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
logger.LogError(ex, "Обработка ассета {AssetId} провалилась", assetId);
await FailAsync(assetId, ex.Message, CancellationToken.None);
}
}
/// <summary>Помечает ассет Processing и возвращает его расширение, либо null если обрабатывать нечего.</summary>
private async Task<string?> BeginProcessingAsync(Guid assetId, CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var asset = await db.MediaAssets.FirstOrDefaultAsync(x => x.Id == assetId, cancellationToken);
if (asset is null || asset.Status is MediaAssetStatus.Ready or MediaAssetStatus.Failed)
return null;
asset.MarkProcessing();
await db.SaveChangesAsync(cancellationToken);
return asset.OriginalExtension;
}
private async Task CompleteAsync(
Guid assetId,
MediaProcessingResult result,
CancellationToken cancellationToken
)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var asset = await db.MediaAssets.FirstOrDefaultAsync(x => x.Id == assetId, cancellationToken);
if (asset is null)
return;
asset.MarkReady(
result.Duration,
result.SegmentSeconds,
result.SegmentCount,
result.Width,
result.Height,
result.VideoCodec,
result.AudioCodec,
result.RelativePath
);
await db.SaveChangesAsync(cancellationToken);
}
private async Task FailAsync(Guid assetId, string error, CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var asset = await db.MediaAssets.FirstOrDefaultAsync(x => x.Id == assetId, cancellationToken);
if (asset is null)
return;
asset.MarkFailed(error);
await db.SaveChangesAsync(cancellationToken);
}
private void DeleteOriginal(Guid assetId, string extension)
{
var original = paths.OriginalPath(assetId, extension);
if (File.Exists(original))
File.Delete(original);
}
}
@@ -0,0 +1,17 @@
using System.Threading.Channels;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Media;
/// <summary>Неограниченная in-memory очередь id ассетов на обработку (один потребитель).</summary>
public sealed class MediaProcessingQueue : IMediaProcessingQueue
{
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
new UnboundedChannelOptions { SingleReader = true }
);
public void Enqueue(Guid assetId) => _channel.Writer.TryWrite(assetId);
public IAsyncEnumerable<Guid> DequeueAllAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAllAsync(cancellationToken);
}
@@ -0,0 +1,85 @@
using System.Diagnostics;
using System.Text;
namespace TeleWave.Infrastructure.Media;
internal sealed record ProcessResult(int ExitCode, string StdOut, string StdErr);
/// <summary>Тонкая обёртка над <see cref="Process"/> для запуска ffmpeg/ffprobe с захватом вывода
/// и понижением приоритета (чтобы транскод не мешал эфиру и API).</summary>
internal static class ProcessRunner
{
public static async Task<ProcessResult> RunAsync(
string fileName,
IEnumerable<string> arguments,
bool lowPriority,
CancellationToken cancellationToken
)
{
var psi = new ProcessStartInfo
{
FileName = fileName,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var arg in arguments)
psi.ArgumentList.Add(arg);
using var process = new Process { StartInfo = psi };
var stdOut = new StringBuilder();
var stdErr = new StringBuilder();
process.OutputDataReceived += (_, e) =>
{
if (e.Data is not null)
stdOut.AppendLine(e.Data);
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data is not null)
stdErr.AppendLine(e.Data);
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
if (lowPriority)
{
try
{
process.PriorityClass = ProcessPriorityClass.BelowNormal;
}
catch (Exception ex) when (ex is InvalidOperationException or PlatformNotSupportedException)
{
// Процесс мог завершиться мгновенно или платформа не поддерживает — не критично.
}
}
try
{
await process.WaitForExitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
TryKill(process);
throw;
}
return new ProcessResult(process.ExitCode, stdOut.ToString(), stdErr.ToString());
}
private static void TryKill(Process process)
{
try
{
if (!process.HasExited)
process.Kill(entireProcessTree: true);
}
catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException)
{
// Уже завершился — игнорируем.
}
}
}
@@ -0,0 +1,18 @@
namespace TeleWave.Infrastructure.Media;
public sealed class StorageOptions
{
public const string SectionName = "Storage";
/// <summary>Корень хранилища внутри контейнера (bind-mount на /media). Фиксируется в env.</summary>
public string RootPath { get; init; } = "/media";
/// <summary>Длина HLS-сегмента в секундах. Длительность ассетов добивается до кратности ей.</summary>
public int SegmentSeconds { get; init; } = 2;
/// <summary>Хранить ли исходник в originals/ после успешной нарезки.</summary>
public bool KeepOriginals { get; init; }
/// <summary>Порог свободного места, ниже которого загрузка отклоняется, байт.</summary>
public long MinFreeSpaceBytes { get; init; } = 10L * 1024 * 1024 * 1024;
}
@@ -0,0 +1,388 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TeleWave.Infrastructure.Persistence;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260724052802_AddMediaAssets")]
partial class AddMediaAssets
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReplacedByTokenHash")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AudioCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<TimeSpan?>("Duration")
.HasColumnType("interval");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int?>("Height")
.HasColumnType("integer");
b.Property<string>("OriginalExtension")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RelativePath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("SegmentCount")
.HasColumnType("integer");
b.Property<int?>("SegmentSeconds")
.HasColumnType("integer");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VideoCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int?>("Width")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("MediaAssets");
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsBlocked")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,58 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddMediaAssets : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "MediaAssets",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OriginalFileName = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
OriginalExtension = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
Source = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
Duration = table.Column<TimeSpan>(type: "interval", nullable: true),
SegmentSeconds = table.Column<int>(type: "integer", nullable: true),
SegmentCount = table.Column<int>(type: "integer", nullable: true),
Width = table.Column<int>(type: "integer", nullable: true),
Height = table.Column<int>(type: "integer", nullable: true),
VideoCodec = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
AudioCodec = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
RelativePath = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ErrorMessage = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MediaAssets", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_MediaAssets_CreatedAt",
table: "MediaAssets",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_MediaAssets_Status",
table: "MediaAssets",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MediaAssets");
}
}
}
@@ -160,6 +160,74 @@ namespace TeleWave.Infrastructure.Migrations
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AudioCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<TimeSpan?>("Duration")
.HasColumnType("interval");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int?>("Height")
.HasColumnType("integer");
b.Property<string>("OriginalExtension")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RelativePath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("SegmentCount")
.HasColumnType("integer");
b.Property<int?>("SegmentSeconds")
.HasColumnType("integer");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VideoCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int?>("Width")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("MediaAssets");
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b =>
{
b.Property<Guid>("Id")
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Auth;
using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Infrastructure.Persistence;
@@ -15,6 +16,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
IAppDbContext
{
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<MediaAsset> MediaAssets => Set<MediaAsset>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeleWave.Domain.Media;
namespace TeleWave.Infrastructure.Persistence.Configurations;
public class MediaAssetConfiguration : IEntityTypeConfiguration<MediaAsset>
{
public void Configure(EntityTypeBuilder<MediaAsset> builder)
{
builder.Property(x => x.OriginalFileName).IsRequired().HasMaxLength(512);
builder.Property(x => x.OriginalExtension).IsRequired().HasMaxLength(16);
builder.Property(x => x.VideoCodec).HasMaxLength(32);
builder.Property(x => x.AudioCodec).HasMaxLength(32);
builder.Property(x => x.RelativePath).HasMaxLength(256);
builder.Property(x => x.ErrorMessage).HasMaxLength(2048);
builder.HasIndex(x => x.Status);
builder.HasIndex(x => x.CreatedAt);
}
}
@@ -10,6 +10,8 @@
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
</ItemGroup>