diff --git a/.env.example b/.env.example index f0d7c03..38bbe0b 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,25 @@ AdminSeed__Password=change-me-strong-admin-password # Лимит запросов/мин на auth-эндпоинты (login/register/refresh). По умолчанию 20. # RateLimiting__AuthPermitLimit=20 +# ── Хранилище медиа ──────────────────────────────────────────────────────── +# RootPath — путь ВНУТРИ контейнера; на хосте это bind-mount тома (см. docs/server-storage-setup.md +# и volumes в docker-compose.yml). Не меняй RootPath без синхронного изменения тома. +Storage__RootPath=/media +Storage__SegmentSeconds=2 +Storage__KeepOriginals=false +# Порог свободного места, ниже которого загрузка отклоняется (10 ГБ). +Storage__MinFreeSpaceBytes=10737418240 + +# ── Обработка медиа (ffmpeg) ─────────────────────────────────────────────── +Media__FfmpegPath=/usr/bin/ffmpeg +Media__FfprobePath=/usr/bin/ffprobe +# Максимальный размер загружаемого файла (20 ГБ). +Media__MaxUploadBytes=21474836480 +# Потоки ffmpeg — оставляем ядро API/раздаче (сервер 4 vCPU). +Media__TranscodeThreads=3 +# Период опроса каталога inbox/ сканером, секунды. +Media__InboxScanSeconds=15 + # ── ASP.NET Core ────────────────────────────────────────────────────────── ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_HTTP_PORTS=8080 diff --git a/.gitignore b/.gitignore index 7242fd5..00a99ee 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,6 @@ dist-ssr/ .env .env.local .env.*.local + +# Локальное медиахранилище для dev-запуска (Storage__RootPath в appsettings.Development.json) +.dev-media/ diff --git a/Dockerfile b/Dockerfile index b03fa26..166f7dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,8 @@ RUN dotnet publish backend/src/TeleWave.Api/TeleWave.Api.csproj -c Release -o /a # ── Stage 3: runtime ────────────────────────────────────────────────────── FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends curl \ +# curl — для HEALTHCHECK; ffmpeg — нормализация и нарезка медиа при загрузке (см. Media__FfmpegPath). +RUN apt-get update && apt-get install -y --no-install-recommends curl ffmpeg \ && rm -rf /var/lib/apt/lists/* ENV ASPNETCORE_ENVIRONMENT=Production \ ASPNETCORE_HTTP_PORTS=8080 diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 29f1396..db2b297 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -25,6 +25,8 @@ + + diff --git a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs new file mode 100644 index 0000000..15dccad --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs @@ -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(StatusCodes.Status201Created); + admin.MapGet("", List).Produces>(); + admin.MapGet("/{id:guid}", Get).Produces(); + admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent); + + return app; + } + + /// + /// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName». + /// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку. + /// + private static async Task Upload( + string fileName, + HttpRequest request, + IMediaStorage storage, + IMediaProcessingQueue queue, + ISender sender, + IOptions mediaOptions, + IOptions 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 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 Get( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new GetMediaAssetQuery(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task 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); diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs index 3843f50..182e026 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -13,6 +13,13 @@ using Serilog; var builder = WebApplication.CreateBuilder(args); +// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки +// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints. +builder.WebHost.ConfigureKestrel(options => + options.Limits.MaxRequestBodySize = + builder.Configuration.GetValue("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(); diff --git a/backend/src/TeleWave.Api/appsettings.Development.json b/backend/src/TeleWave.Api/appsettings.Development.json index c55eb3e..bcc2915 100644 --- a/backend/src/TeleWave.Api/appsettings.Development.json +++ b/backend/src/TeleWave.Api/appsettings.Development.json @@ -8,5 +8,8 @@ "AdminSeed": { "Username": "admin", "Password": "Passw0rd!Dev" + }, + "Storage": { + "RootPath": ".dev-media" } } diff --git a/backend/src/TeleWave.Api/appsettings.json b/backend/src/TeleWave.Api/appsettings.json index 6fd3dd9..39223c5 100644 --- a/backend/src/TeleWave.Api/appsettings.json +++ b/backend/src/TeleWave.Api/appsettings.json @@ -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": { diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs index d1ed002..9772934 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs @@ -1,11 +1,13 @@ using Microsoft.EntityFrameworkCore; using TeleWave.Domain.Auth; +using TeleWave.Domain.Media; namespace TeleWave.Application.Common.Interfaces; public interface IAppDbContext { DbSet RefreshTokens { get; } + DbSet MediaAssets { get; } Task SaveChangesAsync(CancellationToken cancellationToken); } diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IMediaProcessingQueue.cs b/backend/src/TeleWave.Application/Common/Interfaces/IMediaProcessingQueue.cs new file mode 100644 index 0000000..6e3b495 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMediaProcessingQueue.cs @@ -0,0 +1,12 @@ +namespace TeleWave.Application.Common.Interfaces; + +/// +/// Очередь фоновой обработки ассетов. Продюсеры (endpoint загрузки, inbox-сканер) ставят id ассета, +/// единственный фоновый потребитель обрабатывает по одному за раз. +/// +public interface IMediaProcessingQueue +{ + void Enqueue(Guid assetId); + + IAsyncEnumerable DequeueAllAsync(CancellationToken cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IMediaProcessor.cs b/backend/src/TeleWave.Application/Common/Interfaces/IMediaProcessor.cs new file mode 100644 index 0000000..1c60fa5 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMediaProcessor.cs @@ -0,0 +1,29 @@ +using TeleWave.Domain.Media; + +namespace TeleWave.Application.Common.Interfaces; + +/// Итог нормализации и нарезки исходника в HLS-сегменты. +public sealed record MediaProcessingResult( + TimeSpan Duration, + int SegmentSeconds, + int SegmentCount, + int Width, + int Height, + string VideoCodec, + string AudioCodec, + string RelativePath +); + +/// +/// Порт обработки медиа (ffprobe + ffmpeg). Реализация читает исходник по +/// и из хранилища, нормализует в H.264/AAC и режет на сегменты +/// фиксированной длины, добивая длительность до кратности сегменту. +/// +public interface IMediaProcessor +{ + Task ProcessAsync( + Guid assetId, + string extension, + CancellationToken cancellationToken + ); +} diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs b/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs new file mode 100644 index 0000000..3508d65 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs @@ -0,0 +1,37 @@ +using TeleWave.Domain.Media; + +namespace TeleWave.Application.Common.Interfaces; + +/// +/// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня +/// (Storage:RootPath) — защита от path traversal лежит на реализации. +/// +public interface IMediaStorage +{ + /// Свободное место на томе хранилища, байт. + long GetAvailableFreeSpaceBytes(); + + /// + /// Стримит загружаемый контент во временный файл в uploads/ без буферизации в память. + /// Возвращает непрозрачный токен (имя временного файла) для последующего . + /// + Task SaveUploadAsync(Stream content, string extension, CancellationToken cancellationToken); + + /// Удаляет временный файл загрузки (откат при ошибке до регистрации ассета). + void DeleteUpload(string uploadToken); + + /// + /// Переносит исходник из источника (uploads/ для либо + /// inbox/ для ) в originals/{assetId}{ext}. + /// + Task PromoteToOriginalAsync( + MediaSource source, + string sourceToken, + Guid assetId, + string extension, + CancellationToken cancellationToken + ); + + /// Удаляет все артефакты ассета: исходник в originals/ и каталог сегментов assets/{id}/. + void DeleteAssetArtifacts(Guid assetId, string extension); +} diff --git a/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommand.cs b/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommand.cs new file mode 100644 index 0000000..10d5732 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Media.Delete; + +public sealed record DeleteMediaAssetCommand(Guid Id) : ICommand; diff --git a/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs b/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs new file mode 100644 index 0000000..b67b749 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs @@ -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 +{ + public async Task 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(); + } +} diff --git a/backend/src/TeleWave.Application/Media/GetMedia/GetMediaAssetQuery.cs b/backend/src/TeleWave.Application/Media/GetMedia/GetMediaAssetQuery.cs new file mode 100644 index 0000000..cbf1b23 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/GetMedia/GetMediaAssetQuery.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Media.GetMedia; + +public sealed record GetMediaAssetQuery(Guid Id) : IQuery>; diff --git a/backend/src/TeleWave.Application/Media/GetMedia/GetMediaAssetQueryHandler.cs b/backend/src/TeleWave.Application/Media/GetMedia/GetMediaAssetQueryHandler.cs new file mode 100644 index 0000000..13d9fc2 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/GetMedia/GetMediaAssetQueryHandler.cs @@ -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> +{ + public async Task> Handle( + GetMediaAssetQuery query, + CancellationToken cancellationToken + ) + { + var asset = await dbContext.MediaAssets.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == query.Id, cancellationToken); + + return asset is null + ? Result.Failure(MediaErrors.NotFound) + : Result.Success(MediaAssetDto.From(asset)); + } +} diff --git a/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQuery.cs b/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQuery.cs new file mode 100644 index 0000000..71c540b --- /dev/null +++ b/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQuery.cs @@ -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>; diff --git a/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQueryHandler.cs b/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQueryHandler.cs new file mode 100644 index 0000000..1bb5adf --- /dev/null +++ b/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQueryHandler.cs @@ -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> +{ + public async Task> 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(items, total, query.Page, query.PageSize); + } +} diff --git a/backend/src/TeleWave.Application/Media/MediaAssetDto.cs b/backend/src/TeleWave.Application/Media/MediaAssetDto.cs new file mode 100644 index 0000000..3d8fcea --- /dev/null +++ b/backend/src/TeleWave.Application/Media/MediaAssetDto.cs @@ -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 + ); +} diff --git a/backend/src/TeleWave.Application/Media/MediaErrors.cs b/backend/src/TeleWave.Application/Media/MediaErrors.cs new file mode 100644 index 0000000..a2d3d45 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/MediaErrors.cs @@ -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", + "Исходный файл не найден в хранилище." + ); +} diff --git a/backend/src/TeleWave.Application/Media/MediaFormats.cs b/backend/src/TeleWave.Application/Media/MediaFormats.cs new file mode 100644 index 0000000..7819fa7 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/MediaFormats.cs @@ -0,0 +1,29 @@ +namespace TeleWave.Application.Media; + +/// Допустимые контейнеры исходников. Кодеки нормализуются при обработке, поэтому список +/// широкий — важно лишь, что ffmpeg сможет это прочитать. +public static class MediaFormats +{ + public static readonly IReadOnlySet AllowedExtensions = new HashSet( + 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); + } +} diff --git a/backend/src/TeleWave.Application/Media/Register/RegisterMediaAssetCommand.cs b/backend/src/TeleWave.Application/Media/Register/RegisterMediaAssetCommand.cs new file mode 100644 index 0000000..ae60a82 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/Register/RegisterMediaAssetCommand.cs @@ -0,0 +1,16 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Media; + +namespace TeleWave.Application.Media.Register; + +/// +/// Регистрирует уже лежащий в хранилище исходник ( — временный файл в +/// uploads/ или имя файла в inbox/) как в статусе Pending и переносит его в +/// originals/. Постановку в очередь обработки делает вызывающая сторона после коммита. +/// +public sealed record RegisterMediaAssetCommand( + string SourceToken, + MediaSource Source, + string OriginalFileName +) : ICommand>; diff --git a/backend/src/TeleWave.Application/Media/Register/RegisterMediaAssetCommandHandler.cs b/backend/src/TeleWave.Application/Media/Register/RegisterMediaAssetCommandHandler.cs new file mode 100644 index 0000000..4427cc9 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/Register/RegisterMediaAssetCommandHandler.cs @@ -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> +{ + public async Task> 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); + } +} diff --git a/backend/src/TeleWave.Application/Media/Register/RegisterMediaAssetCommandValidator.cs b/backend/src/TeleWave.Application/Media/Register/RegisterMediaAssetCommandValidator.cs new file mode 100644 index 0000000..8d7f7e0 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/Register/RegisterMediaAssetCommandValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace TeleWave.Application.Media.Register; + +public sealed class RegisterMediaAssetCommandValidator : AbstractValidator +{ + public RegisterMediaAssetCommandValidator() + { + RuleFor(x => x.SourceToken).NotEmpty(); + RuleFor(x => x.OriginalFileName) + .NotEmpty() + .MaximumLength(512) + .Must(MediaFormats.IsAllowed) + .WithMessage("Неподдерживаемое расширение файла."); + } +} diff --git a/backend/src/TeleWave.Domain/Media/MediaAsset.cs b/backend/src/TeleWave.Domain/Media/MediaAsset.cs new file mode 100644 index 0000000..6ecc740 --- /dev/null +++ b/backend/src/TeleWave.Domain/Media/MediaAsset.cs @@ -0,0 +1,105 @@ +namespace TeleWave.Domain.Media; + +/// +/// Один видеофайл в библиотеке. После загрузки нормализуется ffmpeg'ом в HLS-сегменты по +/// секунд; длительность добивается до кратности длине сегмента, чтобы +/// эфирная математика (следующий этап) оставалась целочисленной. Rich model: приватные сеттеры, +/// фабрика и поведенческие переходы статуса. +/// +public class MediaAsset +{ + public Guid Id { get; private set; } + + /// Исходное имя файла (для отображения в админке). + public string OriginalFileName { get; private set; } = string.Empty; + + /// Расширение исходника с точкой, в нижнем регистре (например «.mkv»). + public string OriginalExtension { get; private set; } = string.Empty; + + public MediaSource Source { get; private set; } + public MediaAssetStatus Status { get; private set; } + + /// Точная длительность нарезанного ассета (кратна ). + 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; } + + /// Относительный путь каталога сегментов от корня хранилища («assets/{id}»). + public string? RelativePath { get; private set; } + + /// Текст ошибки, если == . + 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(); + } + + /// Сбросить прерванную обработку (Processing после рестарта) обратно в очередь. + public void ResetToPending() + { + Status = MediaAssetStatus.Pending; + ErrorMessage = null; + Touch(); + } + + private void Touch() => UpdatedAt = DateTimeOffset.UtcNow; +} diff --git a/backend/src/TeleWave.Domain/Media/MediaAssetStatus.cs b/backend/src/TeleWave.Domain/Media/MediaAssetStatus.cs new file mode 100644 index 0000000..92c2766 --- /dev/null +++ b/backend/src/TeleWave.Domain/Media/MediaAssetStatus.cs @@ -0,0 +1,17 @@ +namespace TeleWave.Domain.Media; + +/// Жизненный цикл медиа-ассета от загрузки до готовности к эфиру. +public enum MediaAssetStatus +{ + /// Файл загружен, ждёт обработки в очереди. + Pending, + + /// ffmpeg нарезает файл на HLS-сегменты. + Processing, + + /// Нарезка завершена, ассет готов к постановке в эфир. + Ready, + + /// Обработка провалилась (см. ). + Failed, +} diff --git a/backend/src/TeleWave.Domain/Media/MediaSource.cs b/backend/src/TeleWave.Domain/Media/MediaSource.cs new file mode 100644 index 0000000..3bab17e --- /dev/null +++ b/backend/src/TeleWave.Domain/Media/MediaSource.cs @@ -0,0 +1,11 @@ +namespace TeleWave.Domain.Media; + +/// Откуда файл попал в хранилище. +public enum MediaSource +{ + /// Загружен через админку (chunked/stream upload в uploads/). + Upload, + + /// Положен вручную в inbox/ и подобран сканером. + Inbox, +} diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs index c4ade24..4226e0e 100644 --- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -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(); services.AddScoped(); + AddMedia(services, configuration); + return services; } + + /// Хранилище медиа, обработка ffmpeg, очередь и фоновые сервисы (очередь + inbox-сканер). + private static void AddMedia(IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(StorageOptions.SectionName)); + services.Configure(configuration.GetSection(MediaOptions.SectionName)); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddHostedService(); + services.AddHostedService(); + } } diff --git a/backend/src/TeleWave.Infrastructure/Media/FfmpegMediaProcessor.cs b/backend/src/TeleWave.Infrastructure/Media/FfmpegMediaProcessor.cs new file mode 100644 index 0000000..3b11e88 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/FfmpegMediaProcessor.cs @@ -0,0 +1,238 @@ +using System.Globalization; +using System.Text.Json; +using Microsoft.Extensions.Options; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Infrastructure.Media; + +/// +/// Нормализация (H.264/AAC 1080p) и нарезка исходника на HLS-сегменты через ffmpeg. Длительность +/// добивается чёрным кадром + тишиной до кратности длине сегмента — ключевой инвариант эфирной +/// математики (см. docs/media-storage-and-streaming.md). +/// +public sealed class FfmpegMediaProcessor( + MediaPathResolver paths, + IOptions storageOptions, + IOptions mediaOptions +) : IMediaProcessor +{ + private const int MaxWidth = 1920; + private readonly StorageOptions _storage = storageOptions.Value; + private readonly MediaOptions _media = mediaOptions.Value; + + public async Task 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 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 + { + "-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 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); +} diff --git a/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs new file mode 100644 index 0000000..43a9108 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs @@ -0,0 +1,84 @@ +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Media; + +namespace TeleWave.Infrastructure.Media; + +/// Файловая реализация поверх . +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 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); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Media/InboxScannerBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Media/InboxScannerBackgroundService.cs new file mode 100644 index 0000000..2522b05 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/InboxScannerBackgroundService.cs @@ -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; + +/// +/// Периодически сканирует inbox/: файлы с допустимым расширением и стабильным размером (не растут +/// между тиками — значит докопировались) регистрируются как ассеты и уходят в очередь обработки. +/// Регистрация переносит файл в originals/, поэтому повторно он не подхватывается. +/// +public sealed class InboxScannerBackgroundService( + IServiceScopeFactory scopeFactory, + IMediaProcessingQueue queue, + MediaPathResolver paths, + IOptions mediaOptions, + ILogger logger +) : BackgroundService +{ + private readonly MediaOptions _media = mediaOptions.Value; + private readonly Dictionary _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(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(); + + 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); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaOptions.cs b/backend/src/TeleWave.Infrastructure/Media/MediaOptions.cs new file mode 100644 index 0000000..1024fbc --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/MediaOptions.cs @@ -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"; + + /// Максимальный размер загружаемого файла, байт (по умолчанию 20 ГБ). + public long MaxUploadBytes { get; init; } = 20L * 1024 * 1024 * 1024; + + /// Число потоков ffmpeg — оставляем ядро API и раздаче (см. docs, 4 vCPU). + public int TranscodeThreads { get; init; } = 3; + + /// Период опроса inbox/ сканером, секунды. + public int InboxScanSeconds { get; init; } = 15; +} diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs b/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs new file mode 100644 index 0000000..c862cb9 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.Options; + +namespace TeleWave.Infrastructure.Media; + +/// +/// Единая точка резолва путей хранилища + защита от path traversal. Любой путь, собранный из +/// внешних данных (имя загруженного файла, имя из inbox/), проверяется на нахождение внутри корня. +/// +public sealed class MediaPathResolver +{ + private readonly string _root; + + public MediaPathResolver(IOptions 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}"; + + /// Резолвит имя файла внутри uploads/ (токен загрузки), проверяя выход за пределы каталога. + public string UploadPath(string token) => + EnsureWithin(UploadsDir, Path.Combine(UploadsDir, token)); + + /// Резолвит имя файла внутри inbox/, проверяя выход за пределы каталога. + 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; + } +} diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaProcessingBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Media/MediaProcessingBackgroundService.cs new file mode 100644 index 0000000..c73328d --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/MediaProcessingBackgroundService.cs @@ -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; + +/// +/// Единственный потребитель очереди обработки: по одному ассету за раз прогоняет через ffmpeg. +/// На старте восстанавливает прерванные задачи (Pending/Processing) — переживает рестарт/краш. +/// БД-контекст держится короткими отрезками (пометить статус), сам транскод идёт вне scope, чтобы +/// не держать соединение открытым минутами. +/// +public sealed class MediaProcessingBackgroundService( + IMediaProcessingQueue queue, + IServiceScopeFactory scopeFactory, + MediaPathResolver paths, + IMediaProcessor processor, + IOptions storageOptions, + ILogger 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(); + + 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); + } + } + + /// Помечает ассет Processing и возвращает его расширение, либо null если обрабатывать нечего. + private async Task BeginProcessingAsync(Guid assetId, CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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(); + + 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(); + + 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); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaProcessingQueue.cs b/backend/src/TeleWave.Infrastructure/Media/MediaProcessingQueue.cs new file mode 100644 index 0000000..5dcc0aa --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/MediaProcessingQueue.cs @@ -0,0 +1,17 @@ +using System.Threading.Channels; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Infrastructure.Media; + +/// Неограниченная in-memory очередь id ассетов на обработку (один потребитель). +public sealed class MediaProcessingQueue : IMediaProcessingQueue +{ + private readonly Channel _channel = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true } + ); + + public void Enqueue(Guid assetId) => _channel.Writer.TryWrite(assetId); + + public IAsyncEnumerable DequeueAllAsync(CancellationToken cancellationToken) => + _channel.Reader.ReadAllAsync(cancellationToken); +} diff --git a/backend/src/TeleWave.Infrastructure/Media/ProcessRunner.cs b/backend/src/TeleWave.Infrastructure/Media/ProcessRunner.cs new file mode 100644 index 0000000..9375111 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/ProcessRunner.cs @@ -0,0 +1,85 @@ +using System.Diagnostics; +using System.Text; + +namespace TeleWave.Infrastructure.Media; + +internal sealed record ProcessResult(int ExitCode, string StdOut, string StdErr); + +/// Тонкая обёртка над для запуска ffmpeg/ffprobe с захватом вывода +/// и понижением приоритета (чтобы транскод не мешал эфиру и API). +internal static class ProcessRunner +{ + public static async Task RunAsync( + string fileName, + IEnumerable 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) + { + // Уже завершился — игнорируем. + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Media/StorageOptions.cs b/backend/src/TeleWave.Infrastructure/Media/StorageOptions.cs new file mode 100644 index 0000000..80d3996 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/StorageOptions.cs @@ -0,0 +1,18 @@ +namespace TeleWave.Infrastructure.Media; + +public sealed class StorageOptions +{ + public const string SectionName = "Storage"; + + /// Корень хранилища внутри контейнера (bind-mount на /media). Фиксируется в env. + public string RootPath { get; init; } = "/media"; + + /// Длина HLS-сегмента в секундах. Длительность ассетов добивается до кратности ей. + public int SegmentSeconds { get; init; } = 2; + + /// Хранить ли исходник в originals/ после успешной нарезки. + public bool KeepOriginals { get; init; } + + /// Порог свободного места, ниже которого загрузка отклоняется, байт. + public long MinFreeSpaceBytes { get; init; } = 10L * 1024 * 1024 * 1024; +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260724052802_AddMediaAssets.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260724052802_AddMediaAssets.Designer.cs new file mode 100644 index 0000000..9c45053 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260724052802_AddMediaAssets.Designer.cs @@ -0,0 +1,388 @@ +// +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 + { + /// + 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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("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", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", 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", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260724052802_AddMediaAssets.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260724052802_AddMediaAssets.cs new file mode 100644 index 0000000..dd668d9 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260724052802_AddMediaAssets.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class AddMediaAssets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MediaAssets", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OriginalFileName = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + OriginalExtension = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + Source = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "integer", nullable: false), + Duration = table.Column(type: "interval", nullable: true), + SegmentSeconds = table.Column(type: "integer", nullable: true), + SegmentCount = table.Column(type: "integer", nullable: true), + Width = table.Column(type: "integer", nullable: true), + Height = table.Column(type: "integer", nullable: true), + VideoCodec = table.Column(type: "character varying(32)", maxLength: 32, nullable: true), + AudioCodec = table.Column(type: "character varying(32)", maxLength: 32, nullable: true), + RelativePath = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ErrorMessage = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MediaAssets"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 8a82e44..0371c98 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -160,6 +160,74 @@ namespace TeleWave.Infrastructure.Migrations b.ToTable("RefreshTokens"); }); + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => { b.Property("Id") diff --git a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs index 0c17455..e184e32 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs @@ -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 options) IAppDbContext { public DbSet RefreshTokens => Set(); + public DbSet MediaAssets => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/MediaAssetConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/MediaAssetConfiguration.cs new file mode 100644 index 0000000..0a0d833 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/MediaAssetConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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); + } +} diff --git a/backend/src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj b/backend/src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj index 334134c..d507d61 100644 --- a/backend/src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj +++ b/backend/src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj @@ -10,6 +10,8 @@ + + diff --git a/backend/tests/TeleWave.Domain.Tests/Media/MediaAssetTests.cs b/backend/tests/TeleWave.Domain.Tests/Media/MediaAssetTests.cs new file mode 100644 index 0000000..5f05972 --- /dev/null +++ b/backend/tests/TeleWave.Domain.Tests/Media/MediaAssetTests.cs @@ -0,0 +1,79 @@ +using TeleWave.Domain.Media; +using Xunit; + +namespace TeleWave.Domain.Tests.Media; + +public class MediaAssetTests +{ + [Fact] + public void Register_CreatesPendingAsset_WithNormalizedExtension() + { + var asset = MediaAsset.Register("Simpsons S05E01.MKV", ".MKV", MediaSource.Upload); + + Assert.Equal(MediaAssetStatus.Pending, asset.Status); + Assert.Equal(".mkv", asset.OriginalExtension); + Assert.Equal("Simpsons S05E01.MKV", asset.OriginalFileName); + Assert.Equal(MediaSource.Upload, asset.Source); + Assert.NotEqual(Guid.Empty, asset.Id); + } + + [Fact] + public void MarkProcessing_SetsProcessingAndClearsError() + { + var asset = MediaAsset.Register("a.mp4", ".mp4", MediaSource.Inbox); + asset.MarkFailed("boom"); + + asset.MarkProcessing(); + + Assert.Equal(MediaAssetStatus.Processing, asset.Status); + Assert.Null(asset.ErrorMessage); + } + + [Fact] + public void MarkReady_StoresSegmentationMetadata() + { + var asset = MediaAsset.Register("a.mp4", ".mp4", MediaSource.Upload); + + asset.MarkReady( + TimeSpan.FromSeconds(120), + segmentSeconds: 2, + segmentCount: 60, + width: 1920, + height: 1080, + videoCodec: "h264", + audioCodec: "aac", + relativePath: "assets/abc" + ); + + Assert.Equal(MediaAssetStatus.Ready, asset.Status); + Assert.Equal(TimeSpan.FromSeconds(120), asset.Duration); + Assert.Equal(2, asset.SegmentSeconds); + Assert.Equal(60, asset.SegmentCount); + Assert.Equal(1920, asset.Width); + Assert.Equal(1080, asset.Height); + Assert.Equal("assets/abc", asset.RelativePath); + Assert.Null(asset.ErrorMessage); + } + + [Fact] + public void MarkFailed_StoresError() + { + var asset = MediaAsset.Register("a.mp4", ".mp4", MediaSource.Upload); + + asset.MarkFailed("ffmpeg exited 1"); + + Assert.Equal(MediaAssetStatus.Failed, asset.Status); + Assert.Equal("ffmpeg exited 1", asset.ErrorMessage); + } + + [Fact] + public void ResetToPending_FromProcessing_ReturnsToQueue() + { + var asset = MediaAsset.Register("a.mp4", ".mp4", MediaSource.Upload); + asset.MarkProcessing(); + + asset.ResetToPending(); + + Assert.Equal(MediaAssetStatus.Pending, asset.Status); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index aebbd0d..5c50d50 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,13 @@ services: retries: 5 ports: - '8085:8085' + # Медиахранилище: диск sdb смонтирован на хосте в /srv/telewave/media (см. + # docs/server-storage-setup.md), пробрасывается как /media внутрь контейнера. + volumes: + - /srv/telewave/media:/media + # Явный лимит памяти: RAM в VM динамическая, а .NET настраивает GC по видимой при старте — + # детерминированный cgroup-лимит + предсказуемый выбор OOM-killer при транскоде. + mem_limit: 3g restart: unless-stopped logging: driver: json-file diff --git a/docs/media-storage-and-streaming.md b/docs/media-storage-and-streaming.md new file mode 100644 index 0000000..0bbd8d5 --- /dev/null +++ b/docs/media-storage-and-streaming.md @@ -0,0 +1,264 @@ +# Хранение медиа и линейный эфир + +Проектное решение для следующего этапа TeleWave: библиотека контента, автоматическое +программирование каналов и раздача линейного эфира. Документ описывает согласованный дизайн — +реализации пока нет. + +## Что строим + +Админ работает не со слотами вручную, а с **библиотекой шоу** и **правилами канала**: + +1. Загружает контент в библиотеку: **шоу** (сериал с упорядоченными сериями либо полнометражка) и + **рекламные врезки**. Загруженное переиспользуется — одно шоу можно ставить на разные каналы. +2. Создаёт **канал** (например «Мультики»), добавляет в него несколько шоу с **весами** и размером + **блока** (сколько серий подряд крутить за раз), плюс пул рекламы и политику её вставки. +3. **Планировщик** сам строит расписание на несколько дней вперёд: взвешенно-случайно выбирает + шоу, ставит блок из K серий подряд (продолжая сериал с того места, где он остановился), между + блоками/сериями вставляет рекламу — и так встык, без дыр. + +Пример эфира «Мультиков»: 3 серии Симпсонов подряд → реклама → 2 серии другого шоу → реклама → +полнометражка → реклама → снова Симпсоны (следующие невиденные серии) — и дальше по кругу. + +## Принятые решения + +| Вопрос | Решение | +| --- | --- | +| Модель канала | Линейное вещание; программы идут встык, расписание строит система | +| Доставка | Пре-сегментированный HLS; эфирный `m3u8` генерируется математикой, ffmpeg в рантайме не запущен | +| Программирование | Взвешенная случайная ротация шоу; блок подряд за выбор | +| Размер блока | На выбор: по числу серий (K) или по времени (~M минут, набор серий по их длине) | +| Марафоны / override | Отдельная сущность на окне [от, до]: подменённые веса или эксклюзив шоу | +| Библиотека | Общая: шоу и реклама переиспользуются между каналами | +| Порядок серий | Серии сериала идут строго по порядку; «следующая» выводится из хвоста расписания | +| Конец сериала | Дошли до последней серии — по кругу с первой | +| Реклама | Пул на канале, политика вставки настраивается (между блоками / между сериями) | +| Дыры в эфире | В норме их нет (встык); филлер — аварийная подстраховка | +| Длина сегмента | 2 секунды — вход в эфир ≈6с | +| Исходники | Разношёрстные (mkv/avi, HEVC, AC3) — нормализация транскодом обязательна | +| Наполнение | Загрузка через админку **и** ручная укладка в `inbox/` со сканером | +| Путь хранилища | Корень — в env, относительные подпути — в БД | +| Качество | Одно: 1080p, ~4 Мбит/с | +| Оригиналы | Удаляются после успешной нарезки | +| Защита потока | Отдельная httpOnly cookie с ограниченным `Path` | +| Обработка | Сразу после загрузки, с `nice` и `-threads 3` | +| Масштаб | 1–5 каналов, до 10 одновременных зрителей | + +## Доменная модель наполнения + +Всё в стиле существующего `RefreshToken`: rich model, приватные сеттеры, фабрики, поведенческие +методы. + +**Domain** + +- `Media/MediaAsset` — один видеофайл. Статус (`Pending|Processing|Ready|Failed`), точная + длительность (кратная 2с), `SegmentCount`, относительный путь, метаданные (разрешение, кодеки, + битрейт), исходное имя файла. Роль (серия/реклама) определяется тем, откуда на него ссылаются, а + не полем самого ассета; для фильтрации в админке допустима мягкая метка `kind`. +- `Library/Show` — переиспользуемое шоу в общей библиотеке. Имя, описание, `ShowKind` + (`Series` | `Single`), **упорядоченный** список серий (`ShowEpisode`: позиция + `MediaAssetId`). + Полнометражка — это `Single` с одной серией. +- `Broadcast/Channel` — slug, название, вкл/выкл, `EpochUtc`, политика рекламы + (`AdInsertion: BetweenBlocks | BetweenEpisodes`, `AdsPerBreak`), `FillerAssetId` (подстраховка). +- `Broadcast/ChannelShow` — связка канал↔шоу: `Weight` (частота в случайном выборе), `BlockMode` + (`Count` | `Duration`) и `BlockValue` (число серий K либо бюджет в минутах M), вкл/выкл. Для + `Single` блок всегда = 1 серия. «Шоу по одной серии» = `Count`/K=1. +- `Broadcast/ChannelAd` — связка канал↔рекламный ассет: пул врезок канала. +- `Broadcast/ProgrammingOverride` — временный override на канале: окно `[StartsAtUtc, EndsAtUtc]`, + режим (`Exclusive` — только это шоу; `Boost` — подменённые веса), ссылка(и) на шоу. На пересечении + окна с генерируемым временем планировщик использует override вместо базовой ротации. Марафон на + день — это `Exclusive`-override на 24ч с большим временным блоком. Повторяемость (напр. «каждую + субботу») — задел на будущее, в первой версии override разовый. +- `Broadcast/ScheduleEntry` — **материализованная** запись расписания: канал, `MediaAssetId`, + `StartsAtUtc`, `EntryKind` (`Program | Ad`), для программ — `ShowId` и номер серии (для EPG). + Генерируется планировщиком на несколько дней вперёд. + +Курсоров как отдельного состояния **нет**. «Следующая серия шоу X на канале Y» — это чистая +функция от расписания: берём последнюю запланированную серию этого шоу на этом канале, находим её +позицию в упорядоченном списке серий, следующая — `(pos + 1) % count` (отсюда же цикл с начала). +Первая генерация (записей ещё нет) стартует с первой серии. Рекламная ротация выводится так же — +из последней врезки. Единственное персистентное состояние эфира — сами строки `ScheduleEntry`, +рассинхронизироваться нечему. + +## Планировщик + +Доменный сервис `SchedulePlanner` + `BackgroundService`, который держит у каждого включённого +канала расписание, покрывающее `now + HorizonDays` (например 3 дня). Тик раз в +`Scheduler__TickMinutes` дописывает хвост от времени окончания последней записи. + +Генерация одного шага для канала (от `lastEnd`): + +1. **Определить активную политику** на момент `lastEnd`: если время попадает в окно + `ProgrammingOverride` — берём его (`Exclusive` фиксирует одно шоу, `Boost` подменяет веса), иначе + базовую ротацию канала. +2. **Взвешенно-случайный выбор** включённого `ChannelShow` по действующим весам (`System.Random`, + seed на генерацию логируется для воспроизводимости; отдельного состояния не требует, т.к. + результат материализуется). +3. Набрать блок серий подряд от «следующей» (см. вывод из хвоста выше), с заворотом на первую по + достижении конца: + - `BlockMode = Count` — ровно `BlockValue` серий; + - `BlockMode = Duration` — добавлять серии, пока суммарная длительность не достигнет `BlockValue` + минут (учитывается реальная длина каждой серии; последняя серия, переваливающая за бюджет, + либо входит целиком, либо отсекается по правилу «ближе к бюджету» — фиксируем «входит целиком», + чтобы не резать серии). + Каждую серию дописать как `Program` встык. +4. Вставить рекламу по политике канала: `BetweenBlocks` — после блока; `BetweenEpisodes` — после + каждой серии. Взять `AdsPerBreak` врезок из ротации пула, дописать как `Ad`. +5. Повторять, пока не покрыт горизонт. + +Ключевой инвариант — **встык**: `StartsAt` каждой следующей записи равен `StartsAt + Duration` +предыдущей. Так как каждая длительность кратна 2с (см. ниже), все старты автоматически кратны 2с +от эпохи — выравнивание на сегмент держится само собой, без часовой сетки и без дыр. + +**Правка на лету.** Меняются веса/блоки/реклама, состав шоу или добавляется/снимается override → +перегенерируется только будущий хвост от безопасной точки (конец текущей программы + буфер), прошлое +не трогаем. Поскольку «курсор» выводится из хвоста, обрезав будущие записи, планировщик просто +продолжит от последней оставшейся — двойного проигрывания серий не будет. Override, начинающийся +позже, подхватится штатным тиком; override «начиная прямо сейчас» инициирует немедленную +перегенерацию хвоста. + +**Ретеншн.** Прошедшие `ScheduleEntry` старше `Scheduler__RetentionHours` можно удалять (оставляя +небольшое окно назад для EPG «что только что было»). Сегменты ассетов при этом не трогаются — +ассеты переиспользуются, чистится только расписание. + +**Дыры.** В норме их нет. Если у канала нет ни одного включённого шоу с готовыми сериями (или +планировщик отстал) — эфир играет `FillerAssetId` по кругу, пока не появится расписание. + +## Ключевой инвариант: выравнивание на длину сегмента + +Длина сегмента — 2 секунды (`Storage__SegmentSeconds`). При нарезке **длительность каждого ассета +дополняется до кратности 2с** (хвост добивается чёрным кадром). Тогда встык-программирование даёт +старты, кратные 2с, а `EXT-X-MEDIA-SEQUENCE = (now - epoch) / 2` монотонен по построению. + +Без выравнивания хвосты программ были бы некратны сегменту, а счётчик `MEDIA-SEQUENCE` «съезжал» бы +относительно фактического числа сегментов — плееры реагируют пропуском или повтором сегмента. Плата +— до 2с чёрного экрана на стыке, практически незаметно. + +## Построение эфирного окна + +``` +BuildLiveWindow(расписание, филлер, now, windowSegments, segmentSeconds) + → (mediaSequence, discontinuitySequence, сегменты[]) +``` + +Окно — 10 сегментов (20с). Идём назад от `now`, для каждого 2-секундного слота находим запись +расписания (программу или рекламу) и позицию сегмента в её ассете как `(now - StartsAt) / 2`. На +стыке записей ставим `EXT-X-DISCONTINUITY` (склейка разнородного контента и рекламы). Всё время — +UTC; локальный пояс показывает фронтенд. Раздача встык — типичный случай без филлера; филлер +подставляется только при пустом расписании. + +Это самая ответственная часть системы, покрывается юнит-тестами: границы записей, вставка рекламы, +монотонность `MEDIA-SEQUENCE`, правка хвоста на лету, вывод «следующей серии» из расписания. +Нагрузка на API при 10 зрителях — ~5 запросов/с за сегментами и ~5 за плейлистами, пренебрежимо. + +## Раскладка на диске + +Хост: `sdb` (700G) — один раздел ext4, смонтирован в `/srv/telewave/media`, проброшен в контейнер +как `/media` (см. `docs/server-storage-setup.md`). + +``` +/media +├── inbox/ # ручная укладка файлов (SFTP/rsync), подбирается сканером +├── uploads/ # незавершённые chunked-загрузки из админки +├── originals/{assetId}/ # исходник; удаляется после успешной нарезки +└── assets/{assetId}/ + ├── seg00000.ts … # сегменты строго по 2с + └── index.m3u8 # VOD-плейлист ассета (превью в админке) +``` + +Сегменты — **MPEG-TS** (стыки через `EXT-X-DISCONTINUITY` без `EXT-X-MAP`). Ёмкость при 1080p +~4 Мбит/с: час ≈ 1.8 ГБ → **~390 часов** на 700G (оригиналы удаляются). Ассеты переиспользуются +многими каналами и записями расписания, поэтому библиотека растёт медленнее, чем «часы эфира». + +## Обработка при загрузке + +Единственное место, где работает ffmpeg. Исходники разношёрстные, `-c copy` не применим: + +```bash +nice -n 10 ffmpeg -i src \ + -threads 3 \ + -c:v libx264 -preset veryfast -crf 21 -maxrate 4500k -bufsize 9000k \ + -vf "scale='min(1920,iw)':-2" \ + -force_key_frames "expr:gte(t,n_forced*2)" -sc_threshold 0 \ + -c:a aac -b:a 128k -ac 2 -ar 48000 \ + -f hls -hls_time 2 -hls_playlist_type vod -hls_list_size 0 \ + -hls_segment_filename seg%05d.ts index.m3u8 +``` + +- `scale='min(1920,iw)':-2` — не апскейлим то, что меньше 1080p. +- `-force_key_frames expr:gte(t,n_forced*2)` надёжнее фиксированного `-g` при нецелых fps. +- `-sc_threshold 0` убирает сцен-детект, иначе сегменты перестанут быть ровными. +- `maxrate 4500k` — ключевой кадр каждые 2с стоит ~5–10% битрейта. +- `-threads 3` + `nice` оставляют ядро эфиру. Финальный шаг — добивка хвоста до кратности 2с. + +Очередь обработки: `System.Threading.Channels` + `BackgroundService`, строго одна задача за раз. +Inbox-сканер — второй `BackgroundService`: замечает новый файл, ждёт стабилизации размера, заводит +`MediaAsset` в `Pending` и ставит в очередь. Из inbox файлы попадают в библиотеку как отдельные +ассеты; привязка к шоу/сериям — уже действие админа в UI. + +## Слои и API + +**Application** — порты `IMediaStorage` (резолв путей, запись, удаление, свободное место), +`IMediaProcessor` (ffprobe + нарезка), `IMediaProcessingQueue`. Команды: `CreateShow`, +`AddEpisode`, `CreateChannel`, `AddChannelShow` (вес + режим блока), `SetAdPolicy`, `AddChannelAd`, +`CreateProgrammingOverride`, `DeleteProgrammingOverride`, `DeleteMediaAsset`, `RegenerateSchedule`, +`ScanInbox`. Запросы: `GetLivePlaylist`, `GetChannelEpg`, `ListChannels`, `ListShows`, +`ListMediaAssets`. + +**Infrastructure** — `FileSystemMediaStorage`, `FfmpegMediaProcessor`, `SchedulePlanner`, фоновые +сервисы очереди, сканера и планировщика. + +**Api** — публичное: `GET /api/channels`, `GET /api/channels/{slug}/live.m3u8`, +`GET /api/channels/{slug}/epg`, `GET /api/stream/{assetId}/{segment}.ts`. Админское: +`/api/admin/media/*`, `/api/admin/shows/*`, `/api/admin/channels/*`. + +## Конфигурация + +``` +Storage__RootPath=/media +Storage__SegmentSeconds=2 +Storage__LiveWindowSegments=10 +Storage__KeepOriginals=false +Storage__MinFreeSpaceBytes=10737418240 +Media__FfmpegPath=/usr/bin/ffmpeg +Media__FfprobePath=/usr/bin/ffprobe +Media__MaxUploadBytes=21474836480 +Scheduler__HorizonDays=3 +Scheduler__RetentionHours=24 +Scheduler__TickMinutes=30 +``` + +В БД хранятся только **относительные** пути (`assets/{id}`), никогда абсолютные. Смена корня — это +перемонтирование volume и рестарт, поэтому корню место в env, а не в настройках админки. +`IMediaStorage.Resolve(relative)` обязан проверять, что итоговый путь физически внутри корня — +часть путей приходит из имён загруженных файлов, это прямой вектор path traversal. + +## Отдача и защита + +Сегменты иммутабельны → `Cache-Control: public, max-age=31536000, immutable`. Плейлист → +`no-cache`. Отдача через `SendFileAsync`; отдельный nginx не вводится (нарушило бы единый +контейнер). Узкое место на 10 зрителях — сеть (~40 Мбит/с), не CPU. + +Защита — короткоживущая httpOnly cookie `tw_stream` (`Path=/api/stream`, `SameSite=Strict`, +подписанный токен с id пользователя). Работает и с hls.js, и с нативным HLS в Safari/iOS, где +`Authorization` поставить некуда. Продлевается на каждом ответе плейлиста — а тот запрашивается +каждые ~2с, поэтому cookie живёт, пока зритель смотрит. + +## Ресурсы сервера + +Замеры на `tvvm`: **4 vCPU**, память динамическая до 8 ГБ, диск `sdb` 700G. Видеоадаптер — +`1234:1111` (эмулируемый QEMU/Bochs stdvga), в `/dev/dri` только `card0` без `renderD128`: +**аппаратное кодирование недоступно**, транскод только на CPU. `veryfast` 1080p при `-threads 3` — +~1.5–3× realtime (полуторачасовой фильм 30–60 мин в фоне). + +Памяти при 8 ГБ достаточно. Но выделение динамическое, а .NET настраивает GC по памяти при старте, +поэтому в compose нужен явный `mem_limit` (например `3g`) — детерминированный cgroup-лимит вместо +плавающего значения хоста, заодно предсказуемый выбор OOM-killer. Загрузка стримится на диск без +буферизации тела в память: chunked-куски пишутся в `uploads/`, `complete` делает атомарный `move` +внутри той же ФС. + +## Открытые эксплуатационные вопросы + +- Удаление ассета/шоу запрещать, пока на них ссылается будущее расписание или пул канала. +- Свободное место: проверка перед загрузкой и индикация в админке + (`DriveInfo.AvailableFreeSpace`), отказ ниже `Storage__MinFreeSpaceBytes`. +- Что показывать зрителю на границе рекламы в EPG (скрывать врезки или показывать «Реклама»). diff --git a/docs/server-storage-setup.md b/docs/server-storage-setup.md new file mode 100644 index 0000000..f71b2a6 --- /dev/null +++ b/docs/server-storage-setup.md @@ -0,0 +1,200 @@ +# Подготовка диска под медиахранилище (tvvm) + +Runbook: разметка, форматирование и монтирование диска `sdb` (700G) под хранилище TeleWave. +Выполняется **один раз** на сервере `tvvm` перед запуском фичи медиа. Все команды — от root +(`sudo -i` или с `sudo` перед каждой). + +> ⚠️ Команды разметки **уничтожают данные** на целевом диске. Диск `sdb` сейчас пустой (нет +> разделов), но каждый шаг ниже содержит проверку — не пропускайте их. + +Итог: раздел `sdb1` (ext4) смонтирован в `/srv/telewave/media`, внутри созданы рабочие каталоги, +запись из контейнера (сейчас работает под root) возможна. + +--- + +## 0. Проверить, что это тот самый диск + +```bash +lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS,FSTYPE /dev/sdb +``` + +Ожидается: `sdb` размером `700G`, тип `disk`, **без разделов и без точек монтирования**. Убедитесь +дополнительно, что на диске нет файловой системы и подписей: + +```bash +sudo wipefs -n /dev/sdb # -n = «сухой прогон», ничего не меняет +``` + +Если вывод пустой — диск чист, продолжаем. Если что-то нашлось (следы старой ФС/RAID) — +**остановитесь** и разберитесь, что это, прежде чем идти дальше. + +--- + +## 1. Создать таблицу разделов и один раздел + +GPT + один раздел на весь диск: + +```bash +sudo parted -s /dev/sdb mklabel gpt +sudo parted -s -a optimal /dev/sdb mkpart primary ext4 0% 100% +sudo partprobe /dev/sdb +lsblk /dev/sdb +``` + +Должен появиться `sdb1` размером ~700G. + +--- + +## 2. Отформатировать в ext4 + +`-m 1` уменьшает резерв под root с 5% до 1% (на диске данных резерв в 35 ГБ не нужен), `-L` даёт +метку тома: + +```bash +sudo mkfs.ext4 -m 1 -L telewave-media /dev/sdb1 +``` + +--- + +## 3. Создать точку монтирования + +```bash +sudo mkdir -p /srv/telewave/media +``` + +--- + +## 4. Прописать в /etc/fstab (монтирование по UUID) + +Монтируем по UUID, а не по имени `sdb1` — имя может измениться при добавлении дисков. Узнать UUID: + +```bash +sudo blkid /dev/sdb1 +``` + +Скопируйте значение `UUID="..."` и добавьте строку в `/etc/fstab` (подставьте свой UUID): + +``` +UUID=ВАШ-UUID /srv/telewave/media ext4 defaults,noatime,nofail,x-systemd.device-timeout=10 0 2 +``` + +Пояснения к опциям: +- `noatime` — не обновлять время доступа при чтении сегментов; заметно снижает лишние записи при + раздаче видео. +- `nofail` — если диск не подключился, система всё равно загрузится (приложение просто не сможет + писать, а не «висит» на загрузке). +- `x-systemd.device-timeout=10` — не ждать диск дольше 10с при старте. + +Проверить, что fstab корректен, и смонтировать: + +```bash +sudo systemctl daemon-reload +sudo mount -a +findmnt /srv/telewave/media +``` + +`findmnt` должен показать смонтированный `ext4` на `/dev/sdb1`. **Ошибка на этом шаге лучше, чем +на следующей перезагрузке** — если `mount -a` ругается, чините fstab сейчас. + +--- + +## 5. Рабочие каталоги и права + +Создать структуру, ожидаемую приложением: + +```bash +sudo mkdir -p /srv/telewave/media/{inbox,uploads,originals,assets} +``` + +**Владелец.** Контейнер сейчас работает под `root` (в Dockerfile нет `USER`), а bind-mount +пробрасывает права хоста внутрь как есть. Поэтому достаточно оставить владельцем root: + +```bash +sudo chown -R root:root /srv/telewave/media +sudo chmod -R 755 /srv/telewave/media +``` + +Чтобы вы могли класть файлы в `inbox/` вручную (SFTP/rsync) под своим пользователем, откройте на +запись именно этот каталог вашей группе: + +```bash +sudo chown root:$(id -gn) /srv/telewave/media/inbox +sudo chmod 775 /srv/telewave/media/inbox +``` + +> При переходе контейнера на non-root пользователя (если позже добавим `USER` в Dockerfile — +> в aspnet-образе это обычно uid `1654`), сменить владельца на этот uid: +> `sudo chown -R 1654:1654 /srv/telewave/media` (кроме `inbox`, оставленного вам). + +--- + +## 6. Проброс в контейнер (bind mount) + +В `docker-compose.yml`, в сервис `app`, добавить том (этого пока **нет** в репозитории — появится +вместе с реализацией фичи, здесь для справки): + +```yaml +services: + app: + # ... существующая конфигурация ... + volumes: + - /srv/telewave/media:/media + mem_limit: 3g +``` + +И переменные окружения в `.env` (см. `docs/media-storage-and-streaming.md`): + +``` +Storage__RootPath=/media +``` + +Выбран **bind mount**: диск смонтирован в ОС через fstab (шаги 1–5) и виден всегда, независимо от +Docker — это важно, потому что файлы в `inbox/` кладутся вручную по SFTP/rsync, в том числе когда +контейнер остановлен. + +> **Альтернатива — named volume, монтируемый самим Docker** (не используем, для справки). Позволяет +> пропустить шаги 3–5, но диск тогда доступен только при запущенном контейнере, а подкаталоги всё +> равно надо создавать заранее. Пример: +> +> ```yaml +> services: +> app: +> volumes: +> - media:/media +> mem_limit: 3g +> volumes: +> media: +> driver: local +> driver_opts: +> type: ext4 +> device: /dev/disk/by-uuid/ВАШ-UUID +> o: noatime +> ``` +> +> Разметка и `mkfs.ext4` (шаги 1–2) обязательны в любом случае. + +--- + +## 7. Проверка записи из контейнера + +После добавления тома и пересборки образа убедиться, что контейнер пишет на диск: + +```bash +docker compose exec app sh -c 'touch /media/.wtest && ls -l /media/.wtest && rm /media/.wtest' +``` + +Команда должна отработать без ошибок доступа. На этом подготовка хоста завершена — дальнейшее +(создание `MediaAsset`, нарезка ffmpeg) делает уже само приложение. + +--- + +## Приложение: если диск нужно расширить в будущем + +Если VM отдаст диску больше места (например `sdb` вырастет с 700G), после увеличения на стороне +гипервизора: + +```bash +sudo growpart /dev/sdb 1 # расширить раздел на весь диск +sudo resize2fs /dev/sdb1 # расширить ext4 (можно на смонтированном) +df -h /srv/telewave/media +```