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:
@@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
using TeleWave.Infrastructure.Media;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
|
||||
namespace TeleWave.Infrastructure;
|
||||
@@ -85,6 +86,23 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICurrentUser, CurrentUser>();
|
||||
services.AddScoped<DbInitializer>();
|
||||
|
||||
AddMedia(services, configuration);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Хранилище медиа, обработка ffmpeg, очередь и фоновые сервисы (очередь + inbox-сканер).</summary>
|
||||
private static void AddMedia(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<StorageOptions>(configuration.GetSection(StorageOptions.SectionName));
|
||||
services.Configure<MediaOptions>(configuration.GetSection(MediaOptions.SectionName));
|
||||
|
||||
services.AddSingleton<MediaPathResolver>();
|
||||
services.AddSingleton<IMediaStorage, FileSystemMediaStorage>();
|
||||
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
|
||||
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
|
||||
|
||||
services.AddHostedService<MediaProcessingBackgroundService>();
|
||||
services.AddHostedService<InboxScannerBackgroundService>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Нормализация (H.264/AAC 1080p) и нарезка исходника на HLS-сегменты через ffmpeg. Длительность
|
||||
/// добивается чёрным кадром + тишиной до кратности длине сегмента — ключевой инвариант эфирной
|
||||
/// математики (см. docs/media-storage-and-streaming.md).
|
||||
/// </summary>
|
||||
public sealed class FfmpegMediaProcessor(
|
||||
MediaPathResolver paths,
|
||||
IOptions<StorageOptions> storageOptions,
|
||||
IOptions<MediaOptions> mediaOptions
|
||||
) : IMediaProcessor
|
||||
{
|
||||
private const int MaxWidth = 1920;
|
||||
private readonly StorageOptions _storage = storageOptions.Value;
|
||||
private readonly MediaOptions _media = mediaOptions.Value;
|
||||
|
||||
public async Task<MediaProcessingResult> ProcessAsync(
|
||||
Guid assetId,
|
||||
string extension,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var originalPath = paths.OriginalPath(assetId, extension);
|
||||
if (!File.Exists(originalPath))
|
||||
throw new FileNotFoundException("Исходник для обработки не найден.", originalPath);
|
||||
|
||||
var probe = await ProbeAsync(originalPath, cancellationToken);
|
||||
var segmentSeconds = _storage.SegmentSeconds;
|
||||
|
||||
// Целевая длительность — вверх до кратности длине сегмента.
|
||||
var target = Math.Ceiling(probe.Duration / segmentSeconds) * segmentSeconds;
|
||||
var pad = target - probe.Duration;
|
||||
|
||||
var assetDir = paths.AssetDir(assetId);
|
||||
if (Directory.Exists(assetDir))
|
||||
Directory.Delete(assetDir, recursive: true);
|
||||
Directory.CreateDirectory(assetDir);
|
||||
|
||||
var (outWidth, outHeight) = ScaleDown(probe.Width, probe.Height);
|
||||
var args = BuildFfmpegArgs(originalPath, assetDir, segmentSeconds, target, pad);
|
||||
|
||||
var result = await ProcessRunner.RunAsync(
|
||||
_media.FfmpegPath,
|
||||
args,
|
||||
lowPriority: true,
|
||||
cancellationToken
|
||||
);
|
||||
if (result.ExitCode != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"ffmpeg завершился с кодом {result.ExitCode}: {Tail(result.StdErr)}"
|
||||
);
|
||||
|
||||
var playlist = Path.Combine(assetDir, "index.m3u8");
|
||||
if (!File.Exists(playlist))
|
||||
throw new InvalidOperationException("ffmpeg не создал плейлист index.m3u8.");
|
||||
|
||||
var segmentCount = Directory.GetFiles(assetDir, "seg*.ts").Length;
|
||||
if (segmentCount == 0)
|
||||
throw new InvalidOperationException("ffmpeg не создал ни одного сегмента.");
|
||||
|
||||
return new MediaProcessingResult(
|
||||
TimeSpan.FromSeconds(target),
|
||||
segmentSeconds,
|
||||
segmentCount,
|
||||
outWidth,
|
||||
outHeight,
|
||||
"h264",
|
||||
"aac",
|
||||
paths.AssetRelativePath(assetId)
|
||||
);
|
||||
}
|
||||
|
||||
private List<string> BuildFfmpegArgs(
|
||||
string input,
|
||||
string assetDir,
|
||||
int segmentSeconds,
|
||||
double target,
|
||||
double pad
|
||||
)
|
||||
{
|
||||
var padding = pad > 0.001;
|
||||
var vfilter = $"scale='min({MaxWidth},iw)':-2";
|
||||
if (padding)
|
||||
vfilter += $",tpad=stop_duration={Fmt(pad)}:stop_mode=add:color=black";
|
||||
|
||||
var args = new List<string>
|
||||
{
|
||||
"-hide_banner",
|
||||
"-nostdin",
|
||||
"-y",
|
||||
"-i",
|
||||
input,
|
||||
"-threads",
|
||||
_media.TranscodeThreads.ToString(CultureInfo.InvariantCulture),
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"21",
|
||||
"-maxrate",
|
||||
"4500k",
|
||||
"-bufsize",
|
||||
"9000k",
|
||||
"-vf",
|
||||
vfilter,
|
||||
"-force_key_frames",
|
||||
$"expr:gte(t,n_forced*{segmentSeconds.ToString(CultureInfo.InvariantCulture)})",
|
||||
"-sc_threshold",
|
||||
"0",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-ac",
|
||||
"2",
|
||||
"-ar",
|
||||
"48000",
|
||||
};
|
||||
|
||||
if (padding)
|
||||
{
|
||||
args.Add("-af");
|
||||
args.Add($"apad=pad_dur={Fmt(pad)}");
|
||||
args.Add("-t");
|
||||
args.Add(Fmt(target));
|
||||
}
|
||||
|
||||
args.AddRange(
|
||||
[
|
||||
"-f",
|
||||
"hls",
|
||||
"-hls_time",
|
||||
segmentSeconds.ToString(CultureInfo.InvariantCulture),
|
||||
"-hls_playlist_type",
|
||||
"vod",
|
||||
"-hls_list_size",
|
||||
"0",
|
||||
"-hls_segment_filename",
|
||||
Path.Combine(assetDir, "seg%05d.ts"),
|
||||
Path.Combine(assetDir, "index.m3u8"),
|
||||
]
|
||||
);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
private async Task<ProbeInfo> ProbeAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await ProcessRunner.RunAsync(
|
||||
_media.FfprobePath,
|
||||
[
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
path,
|
||||
],
|
||||
lowPriority: false,
|
||||
cancellationToken
|
||||
);
|
||||
if (result.ExitCode != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"ffprobe завершился с кодом {result.ExitCode}: {Tail(result.StdErr)}"
|
||||
);
|
||||
|
||||
using var doc = JsonDocument.Parse(result.StdOut);
|
||||
var root = doc.RootElement;
|
||||
|
||||
double duration = 0;
|
||||
if (
|
||||
root.TryGetProperty("format", out var format)
|
||||
&& format.TryGetProperty("duration", out var durEl)
|
||||
&& double.TryParse(
|
||||
durEl.GetString(),
|
||||
NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var parsed
|
||||
)
|
||||
)
|
||||
duration = parsed;
|
||||
|
||||
int width = 0,
|
||||
height = 0;
|
||||
var hasVideo = false;
|
||||
if (root.TryGetProperty("streams", out var streams))
|
||||
{
|
||||
foreach (var stream in streams.EnumerateArray())
|
||||
{
|
||||
if (!stream.TryGetProperty("codec_type", out var typeEl))
|
||||
continue;
|
||||
if (typeEl.GetString() == "video" && !hasVideo)
|
||||
{
|
||||
hasVideo = true;
|
||||
width = stream.TryGetProperty("width", out var w) ? w.GetInt32() : 0;
|
||||
height = stream.TryGetProperty("height", out var h) ? h.GetInt32() : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (duration <= 0)
|
||||
throw new InvalidOperationException("Не удалось определить длительность файла.");
|
||||
if (!hasVideo || width <= 0 || height <= 0)
|
||||
throw new InvalidOperationException("В файле не найдена видеодорожка.");
|
||||
|
||||
return new ProbeInfo(duration, width, height);
|
||||
}
|
||||
|
||||
private static (int Width, int Height) ScaleDown(int width, int height)
|
||||
{
|
||||
if (width <= MaxWidth)
|
||||
return (width, height);
|
||||
var scaledHeight = (int)Math.Round((double)height * MaxWidth / width);
|
||||
if (scaledHeight % 2 != 0)
|
||||
scaledHeight++;
|
||||
return (MaxWidth, scaledHeight);
|
||||
}
|
||||
|
||||
private static string Fmt(double value) =>
|
||||
value.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string Tail(string text)
|
||||
{
|
||||
text = text.Trim();
|
||||
const int max = 500;
|
||||
return text.Length <= max ? text : text[^max..];
|
||||
}
|
||||
|
||||
private sealed record ProbeInfo(double Duration, int Width, int Height);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>Файловая реализация <see cref="IMediaStorage"/> поверх <see cref="MediaPathResolver"/>.</summary>
|
||||
public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStorage
|
||||
{
|
||||
private const int CopyBufferSize = 1024 * 1024;
|
||||
|
||||
public long GetAvailableFreeSpaceBytes()
|
||||
{
|
||||
try
|
||||
{
|
||||
return new DriveInfo(paths.AssetsDir).AvailableFreeSpace;
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or IOException)
|
||||
{
|
||||
// Не блокируем загрузку, если ФС не отдаёт метрику (например экзотическая точка монтирования).
|
||||
return long.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> SaveUploadAsync(
|
||||
Stream content,
|
||||
string extension,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
Directory.CreateDirectory(paths.UploadsDir);
|
||||
var token = Guid.NewGuid().ToString("N") + extension.ToLowerInvariant();
|
||||
var path = paths.UploadPath(token);
|
||||
|
||||
await using var file = new FileStream(
|
||||
path,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
CopyBufferSize,
|
||||
useAsync: true
|
||||
);
|
||||
await content.CopyToAsync(file, CopyBufferSize, cancellationToken);
|
||||
return token;
|
||||
}
|
||||
|
||||
public void DeleteUpload(string uploadToken)
|
||||
{
|
||||
var path = paths.UploadPath(uploadToken);
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
public Task PromoteToOriginalAsync(
|
||||
MediaSource source,
|
||||
string sourceToken,
|
||||
Guid assetId,
|
||||
string extension,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var sourcePath = source == MediaSource.Inbox
|
||||
? paths.InboxPath(sourceToken)
|
||||
: paths.UploadPath(sourceToken);
|
||||
|
||||
if (!File.Exists(sourcePath))
|
||||
throw new FileNotFoundException("Исходный файл не найден в хранилище.", sourcePath);
|
||||
|
||||
Directory.CreateDirectory(paths.OriginalsDir);
|
||||
var destination = paths.OriginalPath(assetId, extension);
|
||||
File.Move(sourcePath, destination, overwrite: true);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void DeleteAssetArtifacts(Guid assetId, string extension)
|
||||
{
|
||||
var original = paths.OriginalPath(assetId, extension);
|
||||
if (File.Exists(original))
|
||||
File.Delete(original);
|
||||
|
||||
var assetDir = paths.AssetDir(assetId);
|
||||
if (Directory.Exists(assetDir))
|
||||
Directory.Delete(assetDir, recursive: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Media;
|
||||
using TeleWave.Application.Media.Register;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Периодически сканирует inbox/: файлы с допустимым расширением и стабильным размером (не растут
|
||||
/// между тиками — значит докопировались) регистрируются как ассеты и уходят в очередь обработки.
|
||||
/// Регистрация переносит файл в originals/, поэтому повторно он не подхватывается.
|
||||
/// </summary>
|
||||
public sealed class InboxScannerBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IMediaProcessingQueue queue,
|
||||
MediaPathResolver paths,
|
||||
IOptions<MediaOptions> mediaOptions,
|
||||
ILogger<InboxScannerBackgroundService> logger
|
||||
) : BackgroundService
|
||||
{
|
||||
private readonly MediaOptions _media = mediaOptions.Value;
|
||||
private readonly Dictionary<string, long> _lastSizes = new(StringComparer.Ordinal);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
paths.EnsureDirectories();
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Math.Max(1, _media.InboxScanSeconds)));
|
||||
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await ScanAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Ошибка сканирования inbox");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScanAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(paths.InboxDir))
|
||||
return;
|
||||
|
||||
var files = Directory.EnumerateFiles(paths.InboxDir)
|
||||
.Where(f => MediaFormats.IsAllowed(f))
|
||||
.ToList();
|
||||
|
||||
var present = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var path in files)
|
||||
{
|
||||
var name = Path.GetFileName(path);
|
||||
present.Add(name);
|
||||
|
||||
long size;
|
||||
try
|
||||
{
|
||||
size = new FileInfo(path).Length;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
continue; // файл ещё пишется/заблокирован — попробуем на следующем тике
|
||||
}
|
||||
|
||||
if (size <= 0)
|
||||
{
|
||||
_lastSizes[name] = size;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_lastSizes.TryGetValue(name, out var previous) || previous != size)
|
||||
{
|
||||
_lastSizes[name] = size; // размер меняется — ждём стабилизации
|
||||
continue;
|
||||
}
|
||||
|
||||
await RegisterAsync(name, cancellationToken);
|
||||
_lastSizes.Remove(name);
|
||||
}
|
||||
|
||||
// Забываем исчезнувшие файлы, чтобы словарь не рос.
|
||||
foreach (var stale in _lastSizes.Keys.Where(k => !present.Contains(k)).ToList())
|
||||
_lastSizes.Remove(stale);
|
||||
}
|
||||
|
||||
private async Task RegisterAsync(string fileName, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var sender = scope.ServiceProvider.GetRequiredService<ISender>();
|
||||
|
||||
var result = await sender.Send(
|
||||
new RegisterMediaAssetCommand(fileName, MediaSource.Inbox, fileName),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
queue.Enqueue(result.Value);
|
||||
logger.LogInformation("Из inbox зарегистрирован ассет {AssetId} ({File})", result.Value, fileName);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("Не удалось зарегистрировать {File} из inbox: {Error}", fileName, result.Error.Code);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
public sealed class MediaOptions
|
||||
{
|
||||
public const string SectionName = "Media";
|
||||
|
||||
public string FfmpegPath { get; init; } = "ffmpeg";
|
||||
public string FfprobePath { get; init; } = "ffprobe";
|
||||
|
||||
/// <summary>Максимальный размер загружаемого файла, байт (по умолчанию 20 ГБ).</summary>
|
||||
public long MaxUploadBytes { get; init; } = 20L * 1024 * 1024 * 1024;
|
||||
|
||||
/// <summary>Число потоков ffmpeg — оставляем ядро API и раздаче (см. docs, 4 vCPU).</summary>
|
||||
public int TranscodeThreads { get; init; } = 3;
|
||||
|
||||
/// <summary>Период опроса inbox/ сканером, секунды.</summary>
|
||||
public int InboxScanSeconds { get; init; } = 15;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Единая точка резолва путей хранилища + защита от path traversal. Любой путь, собранный из
|
||||
/// внешних данных (имя загруженного файла, имя из inbox/), проверяется на нахождение внутри корня.
|
||||
/// </summary>
|
||||
public sealed class MediaPathResolver
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public MediaPathResolver(IOptions<StorageOptions> options)
|
||||
{
|
||||
_root = Path.GetFullPath(options.Value.RootPath);
|
||||
InboxDir = Path.Combine(_root, "inbox");
|
||||
UploadsDir = Path.Combine(_root, "uploads");
|
||||
OriginalsDir = Path.Combine(_root, "originals");
|
||||
AssetsDir = Path.Combine(_root, "assets");
|
||||
}
|
||||
|
||||
public string InboxDir { get; }
|
||||
public string UploadsDir { get; }
|
||||
public string OriginalsDir { get; }
|
||||
public string AssetsDir { get; }
|
||||
|
||||
public void EnsureDirectories()
|
||||
{
|
||||
Directory.CreateDirectory(InboxDir);
|
||||
Directory.CreateDirectory(UploadsDir);
|
||||
Directory.CreateDirectory(OriginalsDir);
|
||||
Directory.CreateDirectory(AssetsDir);
|
||||
}
|
||||
|
||||
public string OriginalPath(Guid assetId, string extension) =>
|
||||
EnsureWithinRoot(Path.Combine(OriginalsDir, assetId.ToString("N") + extension));
|
||||
|
||||
public string AssetDir(Guid assetId) =>
|
||||
EnsureWithinRoot(Path.Combine(AssetsDir, assetId.ToString("N")));
|
||||
|
||||
public string AssetRelativePath(Guid assetId) => $"assets/{assetId:N}";
|
||||
|
||||
/// <summary>Резолвит имя файла внутри uploads/ (токен загрузки), проверяя выход за пределы каталога.</summary>
|
||||
public string UploadPath(string token) =>
|
||||
EnsureWithin(UploadsDir, Path.Combine(UploadsDir, token));
|
||||
|
||||
/// <summary>Резолвит имя файла внутри inbox/, проверяя выход за пределы каталога.</summary>
|
||||
public string InboxPath(string fileName) =>
|
||||
EnsureWithin(InboxDir, Path.Combine(InboxDir, fileName));
|
||||
|
||||
private string EnsureWithinRoot(string candidate) => EnsureWithin(_root, candidate);
|
||||
|
||||
private static string EnsureWithin(string baseDir, string candidate)
|
||||
{
|
||||
var full = Path.GetFullPath(candidate);
|
||||
var normalizedBase = baseDir.EndsWith(Path.DirectorySeparatorChar)
|
||||
? baseDir
|
||||
: baseDir + Path.DirectorySeparatorChar;
|
||||
|
||||
if (
|
||||
!full.StartsWith(normalizedBase, StringComparison.Ordinal)
|
||||
&& !string.Equals(full, baseDir, StringComparison.Ordinal)
|
||||
)
|
||||
throw new UnauthorizedAccessException(
|
||||
$"Путь '{candidate}' выходит за пределы каталога хранилища."
|
||||
);
|
||||
|
||||
return full;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Единственный потребитель очереди обработки: по одному ассету за раз прогоняет через ffmpeg.
|
||||
/// На старте восстанавливает прерванные задачи (Pending/Processing) — переживает рестарт/краш.
|
||||
/// БД-контекст держится короткими отрезками (пометить статус), сам транскод идёт вне scope, чтобы
|
||||
/// не держать соединение открытым минутами.
|
||||
/// </summary>
|
||||
public sealed class MediaProcessingBackgroundService(
|
||||
IMediaProcessingQueue queue,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
MediaPathResolver paths,
|
||||
IMediaProcessor processor,
|
||||
IOptions<StorageOptions> storageOptions,
|
||||
ILogger<MediaProcessingBackgroundService> logger
|
||||
) : BackgroundService
|
||||
{
|
||||
private readonly StorageOptions _storage = storageOptions.Value;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
paths.EnsureDirectories();
|
||||
await RecoverPendingAsync(stoppingToken);
|
||||
|
||||
await foreach (var assetId in queue.DequeueAllAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessAsync(assetId, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Необработанная ошибка обработки ассета {AssetId}", assetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecoverPendingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||
|
||||
var pending = await db.MediaAssets
|
||||
.Where(x =>
|
||||
x.Status == MediaAssetStatus.Pending || x.Status == MediaAssetStatus.Processing
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var asset in pending.Where(x => x.Status == MediaAssetStatus.Processing))
|
||||
asset.ResetToPending();
|
||||
|
||||
if (pending.Count > 0)
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
foreach (var asset in pending)
|
||||
queue.Enqueue(asset.Id);
|
||||
}
|
||||
|
||||
private async Task ProcessAsync(Guid assetId, CancellationToken cancellationToken)
|
||||
{
|
||||
var extension = await BeginProcessingAsync(assetId, cancellationToken);
|
||||
if (extension is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await processor.ProcessAsync(assetId, extension, cancellationToken);
|
||||
await CompleteAsync(assetId, result, cancellationToken);
|
||||
|
||||
if (!_storage.KeepOriginals)
|
||||
DeleteOriginal(assetId, extension);
|
||||
|
||||
logger.LogInformation(
|
||||
"Ассет {AssetId} обработан: {Segments} сегментов, {Seconds:0.#}с",
|
||||
assetId,
|
||||
result.SegmentCount,
|
||||
result.Duration.TotalSeconds
|
||||
);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Обработка ассета {AssetId} провалилась", assetId);
|
||||
await FailAsync(assetId, ex.Message, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Помечает ассет Processing и возвращает его расширение, либо null если обрабатывать нечего.</summary>
|
||||
private async Task<string?> BeginProcessingAsync(Guid assetId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||
|
||||
var asset = await db.MediaAssets.FirstOrDefaultAsync(x => x.Id == assetId, cancellationToken);
|
||||
if (asset is null || asset.Status is MediaAssetStatus.Ready or MediaAssetStatus.Failed)
|
||||
return null;
|
||||
|
||||
asset.MarkProcessing();
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return asset.OriginalExtension;
|
||||
}
|
||||
|
||||
private async Task CompleteAsync(
|
||||
Guid assetId,
|
||||
MediaProcessingResult result,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||
|
||||
var asset = await db.MediaAssets.FirstOrDefaultAsync(x => x.Id == assetId, cancellationToken);
|
||||
if (asset is null)
|
||||
return;
|
||||
|
||||
asset.MarkReady(
|
||||
result.Duration,
|
||||
result.SegmentSeconds,
|
||||
result.SegmentCount,
|
||||
result.Width,
|
||||
result.Height,
|
||||
result.VideoCodec,
|
||||
result.AudioCodec,
|
||||
result.RelativePath
|
||||
);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task FailAsync(Guid assetId, string error, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||
|
||||
var asset = await db.MediaAssets.FirstOrDefaultAsync(x => x.Id == assetId, cancellationToken);
|
||||
if (asset is null)
|
||||
return;
|
||||
|
||||
asset.MarkFailed(error);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private void DeleteOriginal(Guid assetId, string extension)
|
||||
{
|
||||
var original = paths.OriginalPath(assetId, extension);
|
||||
if (File.Exists(original))
|
||||
File.Delete(original);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Threading.Channels;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>Неограниченная in-memory очередь id ассетов на обработку (один потребитель).</summary>
|
||||
public sealed class MediaProcessingQueue : IMediaProcessingQueue
|
||||
{
|
||||
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
|
||||
new UnboundedChannelOptions { SingleReader = true }
|
||||
);
|
||||
|
||||
public void Enqueue(Guid assetId) => _channel.Writer.TryWrite(assetId);
|
||||
|
||||
public IAsyncEnumerable<Guid> DequeueAllAsync(CancellationToken cancellationToken) =>
|
||||
_channel.Reader.ReadAllAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
internal sealed record ProcessResult(int ExitCode, string StdOut, string StdErr);
|
||||
|
||||
/// <summary>Тонкая обёртка над <see cref="Process"/> для запуска ffmpeg/ffprobe с захватом вывода
|
||||
/// и понижением приоритета (чтобы транскод не мешал эфиру и API).</summary>
|
||||
internal static class ProcessRunner
|
||||
{
|
||||
public static async Task<ProcessResult> RunAsync(
|
||||
string fileName,
|
||||
IEnumerable<string> arguments,
|
||||
bool lowPriority,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (var arg in arguments)
|
||||
psi.ArgumentList.Add(arg);
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
var stdOut = new StringBuilder();
|
||||
var stdErr = new StringBuilder();
|
||||
process.OutputDataReceived += (_, e) =>
|
||||
{
|
||||
if (e.Data is not null)
|
||||
stdOut.AppendLine(e.Data);
|
||||
};
|
||||
process.ErrorDataReceived += (_, e) =>
|
||||
{
|
||||
if (e.Data is not null)
|
||||
stdErr.AppendLine(e.Data);
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
if (lowPriority)
|
||||
{
|
||||
try
|
||||
{
|
||||
process.PriorityClass = ProcessPriorityClass.BelowNormal;
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or PlatformNotSupportedException)
|
||||
{
|
||||
// Процесс мог завершиться мгновенно или платформа не поддерживает — не критично.
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
TryKill(process);
|
||||
throw;
|
||||
}
|
||||
|
||||
return new ProcessResult(process.ExitCode, stdOut.ToString(), stdErr.ToString());
|
||||
}
|
||||
|
||||
private static void TryKill(Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException)
|
||||
{
|
||||
// Уже завершился — игнорируем.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
public sealed class StorageOptions
|
||||
{
|
||||
public const string SectionName = "Storage";
|
||||
|
||||
/// <summary>Корень хранилища внутри контейнера (bind-mount на /media). Фиксируется в env.</summary>
|
||||
public string RootPath { get; init; } = "/media";
|
||||
|
||||
/// <summary>Длина HLS-сегмента в секундах. Длительность ассетов добивается до кратности ей.</summary>
|
||||
public int SegmentSeconds { get; init; } = 2;
|
||||
|
||||
/// <summary>Хранить ли исходник в originals/ после успешной нарезки.</summary>
|
||||
public bool KeepOriginals { get; init; }
|
||||
|
||||
/// <summary>Порог свободного места, ниже которого загрузка отклоняется, байт.</summary>
|
||||
public long MinFreeSpaceBytes { get; init; } = 10L * 1024 * 1024 * 1024;
|
||||
}
|
||||
+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");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AudioCodec")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<TimeSpan?>("Duration")
|
||||
.HasColumnType("interval");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int?>("Height")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("OriginalExtension")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("RelativePath")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<int?>("SegmentCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("SegmentSeconds")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Source")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("VideoCodec")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<int?>("Width")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("MediaAssets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Auth;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
namespace TeleWave.Infrastructure.Persistence;
|
||||
@@ -15,6 +16,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
IAppDbContext
|
||||
{
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<MediaAsset> MediaAssets => Set<MediaAsset>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
+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.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||
</ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user