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