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
+```