Refactor .gitignore to streamline ignored files and enhance clarity. Update CLAUDE.md to improve unit test instructions and add coverage reporting details. Revise README.md for better project overview and deployment instructions. Refactor ChannelEndpoints and StreamingEndpoints to utilize SegmentFiles for file resolution, improving code maintainability. Remove unused JunctionHandlers and update DependencyInjection for cleaner service registration. Enhance media processing services for better job handling and error management. Update frontend API types for consistency and clarity.
This commit is contained in:
@@ -8,10 +8,6 @@ using Microsoft.IdentityModel.Tokens;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Broadcast.Scheduling;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Library.Genres;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Application.Programming.Planning;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
using TeleWave.Infrastructure.Broadcast;
|
||||
@@ -113,15 +109,6 @@ public static class DependencyInjection
|
||||
services.AddScoped<ISiteSettings, SiteSettings>();
|
||||
services.AddScoped<DbInitializer>();
|
||||
services.AddScoped<GenreSeeder>();
|
||||
services.AddScoped<GenreMatcher>();
|
||||
services.AddScoped<GroupElementResolver>();
|
||||
services.AddScoped<GroupStatsService>();
|
||||
services.AddScoped<GroupMembershipCleaner>();
|
||||
services.AddScoped<SlotWriter>();
|
||||
services.AddScoped<GroupExpander>();
|
||||
services.AddScoped<BumperResolver>();
|
||||
services.AddScoped<PostCheckRunner>();
|
||||
services.AddScoped<GridScheduleGenerator>();
|
||||
|
||||
AddMedia(services, configuration);
|
||||
AddBroadcast(services, configuration);
|
||||
|
||||
@@ -1,154 +1,76 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Broadcast.Scheduling;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>Захваченная на рендер заставка: спецификация собирается уже в самой работе.</summary>
|
||||
internal sealed record BumperRenderJob(Guid AssetId);
|
||||
|
||||
/// <summary>
|
||||
/// Асинхронно рендерит ТВ-заставки расписания: планировщик лишь создаёт ассет (Source=Generated) в
|
||||
/// статусе Pending и кэш-строку <see cref="Domain.Broadcast.BumperAsset"/>, а сам ffmpeg крутится здесь,
|
||||
/// вне тика планировщика и его транзакции. Источник истины — статус в БД (последовательно берём
|
||||
/// следующий Pending c Source=Generated, помечаем Processing), поэтому рестарт/краш ничего не теряет
|
||||
/// (прерванные Processing сбрасываются в Pending на старте). До готовности ассета плейлист отдаёт филлер.
|
||||
/// вне тика планировщика и его транзакции. Захват работы и устойчивость к рестарту — в
|
||||
/// <see cref="MediaClaimingBackgroundService{TJob}"/>. До готовности ассета плейлист отдаёт филлер.
|
||||
///
|
||||
/// Рендер строго последовательный: ffmpeg заставки короткий, а параллелить его смысла нет —
|
||||
/// очередь разбирается быстрее, чем планировщик успевает её пополнять.
|
||||
/// </summary>
|
||||
public sealed class BumperRenderBackgroundService(
|
||||
internal sealed class BumperRenderBackgroundService(
|
||||
IBumperRenderQueue queue,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IBumperRenderer renderer,
|
||||
IBumperTemplateStorage bumperStorage,
|
||||
IImageStore imageStore,
|
||||
IOptions<BumperOptions> bumperOptions,
|
||||
IOptions<StreamingOptions> streamingOptions,
|
||||
ILogger<BumperRenderBackgroundService> logger
|
||||
) : BackgroundService
|
||||
) : MediaClaimingBackgroundService<BumperRenderJob>(scopeFactory, logger)
|
||||
{
|
||||
private static readonly TimeSpan IdlePoll = TimeSpan.FromSeconds(30);
|
||||
private readonly BumperOptions _bumper = bumperOptions.Value;
|
||||
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
||||
protected override bool HandlesGenerated => true;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await ResetInterruptedAsync(stoppingToken);
|
||||
protected override string LoopErrorMessage => "Ошибка цикла рендера заставок";
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Разбираем всю накопившуюся работу из БД.
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var assetId = await ClaimNextAsync(stoppingToken);
|
||||
if (assetId is not { } id)
|
||||
break;
|
||||
await RenderClaimedAsync(id, stoppingToken);
|
||||
}
|
||||
protected override ValueTask WaitForWorkAsync(CancellationToken cancellationToken) =>
|
||||
queue.WaitAsync(cancellationToken);
|
||||
|
||||
using var wake = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
wake.CancelAfter(IdlePoll);
|
||||
try
|
||||
{
|
||||
await queue.WaitAsync(wake.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Тайм-аут опроса — просто перепроверяем БД.
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Ошибка цикла рендера заставок");
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
protected override BumperRenderJob ToJob(MediaAsset asset) => new(asset.Id);
|
||||
|
||||
/// <summary>Сброс прерванных рестартом заставок (Generated Processing → Pending) на старте.</summary>
|
||||
private async Task ResetInterruptedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||
|
||||
var interrupted = await db
|
||||
.MediaAssets.Where(x =>
|
||||
x.Status == MediaAssetStatus.Processing && x.Source == MediaSource.Generated
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (interrupted.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var asset in interrupted)
|
||||
asset.ResetToPending();
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Атомарно захватывает самую раннюю Pending-заставку (Generated): Pending → Processing.</summary>
|
||||
private async Task<Guid?> ClaimNextAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||
|
||||
var asset = await db
|
||||
.MediaAssets.Where(x =>
|
||||
x.Status == MediaAssetStatus.Pending && x.Source == MediaSource.Generated
|
||||
)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (asset is null)
|
||||
return null;
|
||||
|
||||
asset.MarkProcessing();
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return asset.Id;
|
||||
}
|
||||
|
||||
private async Task RenderClaimedAsync(Guid assetId, CancellationToken cancellationToken)
|
||||
protected override async Task ProcessAsync(
|
||||
BumperRenderJob job,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||
|
||||
var spec = await BuildSpecAsync(db, assetId, cancellationToken);
|
||||
var spec = await WithScopeAsync<BumperSpecLoader, BumperRenderSpec?>(
|
||||
loader => loader.LoadAsync(job.AssetId, cancellationToken)
|
||||
);
|
||||
if (spec is null)
|
||||
{
|
||||
await FailAsync(
|
||||
assetId,
|
||||
job.AssetId,
|
||||
"Не удалось восстановить спецификацию заставки",
|
||||
cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var render = await renderer.RenderAsync(assetId, spec, cancellationToken);
|
||||
var render = await renderer.RenderAsync(job.AssetId, spec, cancellationToken);
|
||||
|
||||
var asset = await db.MediaAssets.FirstOrDefaultAsync(
|
||||
a => a.Id == assetId,
|
||||
await WithAssetAsync(
|
||||
job.AssetId,
|
||||
asset =>
|
||||
asset.MarkReady(
|
||||
render.Duration,
|
||||
render.SegmentSeconds,
|
||||
render.SegmentCount,
|
||||
render.Width,
|
||||
render.Height,
|
||||
"h264",
|
||||
"aac",
|
||||
render.RelativePath
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
if (asset is null)
|
||||
return;
|
||||
asset.MarkReady(
|
||||
render.Duration,
|
||||
render.SegmentSeconds,
|
||||
render.SegmentCount,
|
||||
render.Width,
|
||||
render.Height,
|
||||
"h264",
|
||||
"aac",
|
||||
render.RelativePath
|
||||
);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -156,126 +78,8 @@ public sealed class BumperRenderBackgroundService(
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Рендер заставки {AssetId} провалился", assetId);
|
||||
await FailAsync(assetId, ex.Message, CancellationToken.None);
|
||||
logger.LogError(ex, "Рендер заставки {AssetId} провалился", job.AssetId);
|
||||
await FailAsync(job.AssetId, ex.Message, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Восстанавливает <see cref="BumperRenderSpec"/> по кэш-строке заставки (канал/блок/подблок).</summary>
|
||||
private async Task<BumperRenderSpec?> BuildSpecAsync(
|
||||
IAppDbContext db,
|
||||
Guid assetId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var cache = await db
|
||||
.BumperAssets.AsNoTracking()
|
||||
.Where(b => b.MediaAssetId == assetId)
|
||||
.OrderByDescending(b => b.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (cache is null)
|
||||
return null;
|
||||
|
||||
var channel = await db
|
||||
.Channels.AsNoTracking()
|
||||
.Include(c => c.BumperTemplates)
|
||||
.ThenInclude(t => t.Variants)
|
||||
.FirstOrDefaultAsync(c => c.Id == cache.ChannelId, cancellationToken);
|
||||
var template = channel?.BumperTemplates.FirstOrDefault(t => t.Id == cache.TemplateId);
|
||||
var variant = template?.Variants.FirstOrDefault(v => v.Id == cache.VariantId);
|
||||
if (channel is null || template is null || variant is null)
|
||||
return null;
|
||||
|
||||
var names = await db
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => s.Id == cache.FromShowId || s.Id == cache.ToShowId)
|
||||
.Select(s => new { s.Id, s.Name })
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||
var fromName = names.GetValueOrDefault(cache.FromShowId, "…");
|
||||
var toName = names.GetValueOrDefault(cache.ToShowId, "…");
|
||||
|
||||
// Постер шоу-получателя как фон — только для «Сейчас/Далее».
|
||||
string? posterPath = null;
|
||||
if (variant.Kind == Domain.Broadcast.BumperTextKind.NowNext)
|
||||
posterPath = await ResolveShowPosterAsync(db, cache.ToShowId, cancellationToken);
|
||||
|
||||
var bgPath = await ResolveTemplateBackgroundAsync(
|
||||
db,
|
||||
template.BackgroundImageId,
|
||||
cancellationToken
|
||||
);
|
||||
var aligned = BumperDuration.Aligned(
|
||||
BumperDuration.TemplateSeconds(template),
|
||||
_segmentSeconds
|
||||
);
|
||||
var audioPath = bumperStorage.AudioPath(template.Id, template.AudioExtension);
|
||||
|
||||
return BumperSpecFactory.Build(
|
||||
_bumper,
|
||||
channel.BumperFont,
|
||||
template,
|
||||
variant,
|
||||
aligned,
|
||||
fromName,
|
||||
toName,
|
||||
audioPath,
|
||||
posterPath,
|
||||
bgPath
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveShowPosterAsync(
|
||||
IAppDbContext db,
|
||||
Guid showId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var posterImageId = await db
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => s.Id == showId && s.PosterImageId != null)
|
||||
.Select(s => s.PosterImageId!.Value)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (posterImageId == Guid.Empty)
|
||||
return null;
|
||||
return await ResolveImagePathAsync(db, posterImageId, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveTemplateBackgroundAsync(
|
||||
IAppDbContext db,
|
||||
Guid? backgroundImageId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (backgroundImageId is not { } bgId)
|
||||
return null;
|
||||
return await ResolveImagePathAsync(db, bgId, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveImagePathAsync(
|
||||
IAppDbContext db,
|
||||
Guid imageId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var ext = await db
|
||||
.Images.AsNoTracking()
|
||||
.Where(i => i.Id == imageId)
|
||||
.Select(i => i.FileExtension)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return ext is null ? null : imageStore.ResolvePath(imageId, ext);
|
||||
}
|
||||
|
||||
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(
|
||||
a => a.Id == assetId,
|
||||
cancellationToken
|
||||
);
|
||||
if (asset is null)
|
||||
return;
|
||||
asset.MarkFailed(error);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq.Expressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Каркас воркера, разбирающего очередь ассетов из БД. Источник истины — статус: единственный
|
||||
/// диспетчер последовательно и атомарно захватывает следующий Pending (помечает Processing), поэтому
|
||||
/// два воркера никогда не возьмут один ассет; сама работа идёт в фоне с ограничением по числу слотов.
|
||||
/// Рестарт/краш ничего не теряет — прерванные Processing сбрасываются в Pending на старте.
|
||||
/// БД-контекст держится короткими отрезками (пометить статус), сама работа идёт вне scope, чтобы не
|
||||
/// держать соединение минутами.
|
||||
///
|
||||
/// Пространство ассетов делится по <see cref="HandlesGenerated"/>: ТВ-заставки (Source=Generated)
|
||||
/// рендерит один воркер, всё остальное транскодирует другой, и пересечься они не могут.
|
||||
/// </summary>
|
||||
internal abstract class MediaClaimingBackgroundService<TJob>(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger logger
|
||||
) : BackgroundService
|
||||
where TJob : class
|
||||
{
|
||||
// Периодически перепроверяем БД, даже если сигнал не пришёл — страховка на любой случай.
|
||||
private static readonly TimeSpan IdlePoll = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>true — воркер обслуживает только сгенерированные ассеты, false — только остальные.</summary>
|
||||
protected abstract bool HandlesGenerated { get; }
|
||||
|
||||
/// <summary>Сколько работ выполняется одновременно. 1 — строго последовательно.</summary>
|
||||
protected virtual int MaxParallel => 1;
|
||||
|
||||
/// <summary>Что писать в лог при сбое цикла (не самой работы).</summary>
|
||||
protected abstract string LoopErrorMessage { get; }
|
||||
|
||||
/// <summary>Ожидание сигнала о новой работе — у каждого воркера своя очередь-будильник.</summary>
|
||||
protected abstract ValueTask WaitForWorkAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Что из захваченного ассета нужно воркеру: работа идёт уже без БД-контекста.</summary>
|
||||
protected abstract TJob ToJob(MediaAsset asset);
|
||||
|
||||
/// <summary>Сама работа над захваченным (уже Processing) ассетом.</summary>
|
||||
protected abstract Task ProcessAsync(TJob job, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Разовая подготовка перед первым тиком (например, создание каталогов).</summary>
|
||||
protected virtual void OnStarting() { }
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
OnStarting();
|
||||
await ResetInterruptedAsync(stoppingToken);
|
||||
|
||||
// Слоты параллелизма: не запускаем больше MaxParallel работ одновременно.
|
||||
using var slots = new SemaphoreSlim(MaxParallel, MaxParallel);
|
||||
var inFlight = new ConcurrentDictionary<Task, byte>();
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Захватываем и раздаём по слотам всю накопившуюся работу из БД.
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await slots.WaitAsync(stoppingToken);
|
||||
|
||||
// Слот уже захвачен — любой сбой захвата ассета (транзиентная ошибка БД и т.п.)
|
||||
// обязан вернуть слот, иначе после нескольких ошибок семафор исчерпается и
|
||||
// диспетчер зависнет навсегда (сервис формально жив, но ничего не обрабатывает).
|
||||
TJob? claim;
|
||||
try
|
||||
{
|
||||
claim = await ClaimNextAsync(stoppingToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
slots.Release();
|
||||
throw;
|
||||
}
|
||||
|
||||
if (claim is not { } job)
|
||||
{
|
||||
slots.Release();
|
||||
break;
|
||||
}
|
||||
|
||||
var task = Task.Run(
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessAsync(job, stoppingToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
slots.Release();
|
||||
}
|
||||
},
|
||||
CancellationToken.None
|
||||
);
|
||||
inFlight[task] = 0;
|
||||
_ = task.ContinueWith(t => inFlight.TryRemove(t, out _), TaskScheduler.Default);
|
||||
}
|
||||
|
||||
// Работы нет — ждём сигнала о новой либо периодического опроса.
|
||||
using var wake = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
wake.CancelAfter(IdlePoll);
|
||||
try
|
||||
{
|
||||
await WaitForWorkAsync(wake.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Тайм-аут опроса — просто перепроверяем БД.
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "{Message}", LoopErrorMessage);
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Даём уже запущенным работам корректно завершиться (или отмениться) на остановке.
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(inFlight.Keys.ToArray());
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ошибки/отмена отдельных задач уже залогированы внутри ProcessAsync.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Помечает ассет провалившимся. Вызывается воркером из его обработчика ошибок.</summary>
|
||||
protected async Task FailAsync(
|
||||
Guid assetId,
|
||||
string error,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
await WithAssetAsync(
|
||||
assetId,
|
||||
asset => asset.MarkFailed(error),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>Находит ассет в свежем scope, применяет к нему изменение и сохраняет.</summary>
|
||||
protected async Task WithAssetAsync(
|
||||
Guid assetId,
|
||||
Action<MediaAsset> change,
|
||||
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;
|
||||
|
||||
change(asset);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Выполняет что-то на scoped-сервисе в отдельном scope — для сборки данных под работу.
|
||||
/// Scope живёт только на время вызова: соединение с БД не удерживается на весь рендер/транскод.
|
||||
/// </summary>
|
||||
protected async Task<T> WithScopeAsync<TService, T>(Func<TService, Task<T>> use)
|
||||
where TService : notnull
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
return await use(scope.ServiceProvider.GetRequiredService<TService>());
|
||||
}
|
||||
|
||||
/// <summary>Сброс прерванных рестартом задач (Processing → Pending) на старте.</summary>
|
||||
private async Task ResetInterruptedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||
|
||||
var interrupted = await db
|
||||
.MediaAssets.Where(Owned(MediaAssetStatus.Processing))
|
||||
.ToListAsync(cancellationToken);
|
||||
if (interrupted.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var asset in interrupted)
|
||||
asset.ResetToPending();
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Атомарно захватывает самый ранний Pending этого воркера: помечает его Processing и возвращает
|
||||
/// job, либо null если работы нет. Вызывается только диспетчером последовательно, поэтому две
|
||||
/// работы не возьмут один ассет.
|
||||
/// </summary>
|
||||
private async Task<TJob?> ClaimNextAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||
|
||||
var asset = await db
|
||||
.MediaAssets.Where(Owned(MediaAssetStatus.Pending))
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (asset is null)
|
||||
return default;
|
||||
|
||||
asset.MarkProcessing();
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return ToJob(asset);
|
||||
}
|
||||
|
||||
/// <summary>Ассеты этого воркера в заданном статусе — предикат переводится в SQL.</summary>
|
||||
private Expression<Func<MediaAsset, bool>> Owned(MediaAssetStatus status) =>
|
||||
HandlesGenerated
|
||||
? x => x.Status == status && x.Source == MediaSource.Generated
|
||||
: x => x.Status == status && x.Source != MediaSource.Generated;
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
@@ -9,16 +6,15 @@ using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>Захваченный на транскод ассет: расширение нужно ffmpeg и уже не требует БД.</summary>
|
||||
internal sealed record MediaTranscodeJob(Guid AssetId, string Extension);
|
||||
|
||||
/// <summary>
|
||||
/// Обработчик медиа: прогоняет ассеты через ffmpeg, до <see cref="MediaOptions.MaxParallelTranscodes"/>
|
||||
/// файлов одновременно. Источник истины — статус в БД: единственный диспетчер последовательно и
|
||||
/// атомарно захватывает следующий Pending (помечает Processing), поэтому два транскода никогда не
|
||||
/// возьмут один ассет; сам транскод запускается в фоне с ограничением по числу слотов. Рестарт/краш
|
||||
/// ничего не теряет — незавершённые подхватываются из БД (прерванные Processing на старте сбрасываются
|
||||
/// в Pending). БД-контекст держится короткими отрезками (пометить статус), сам транскод идёт вне
|
||||
/// scope, чтобы не держать соединение минутами.
|
||||
/// файлов одновременно. Захват работы, устойчивость к рестарту и параллелизм — в
|
||||
/// <see cref="MediaClaimingBackgroundService{TJob}"/>; здесь только сам транскод.
|
||||
/// </summary>
|
||||
public sealed class MediaProcessingBackgroundService(
|
||||
internal sealed class MediaProcessingBackgroundService(
|
||||
IMediaProcessingQueue queue,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
MediaPathResolver paths,
|
||||
@@ -26,167 +22,55 @@ public sealed class MediaProcessingBackgroundService(
|
||||
IOptions<StorageOptions> storageOptions,
|
||||
IOptions<MediaOptions> mediaOptions,
|
||||
ILogger<MediaProcessingBackgroundService> logger
|
||||
) : BackgroundService
|
||||
) : MediaClaimingBackgroundService<MediaTranscodeJob>(scopeFactory, logger)
|
||||
{
|
||||
// Периодически перепроверяем БД, даже если сигнал не пришёл — страховка на любой случай.
|
||||
private static readonly TimeSpan IdlePoll = TimeSpan.FromSeconds(30);
|
||||
private readonly StorageOptions _storage = storageOptions.Value;
|
||||
private readonly int _maxParallel = Math.Max(1, mediaOptions.Value.MaxParallelTranscodes);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
paths.EnsureDirectories();
|
||||
await ResetInterruptedAsync(stoppingToken);
|
||||
// Generated-ассеты (ТВ-заставки) обслуживает BumperRenderBackgroundService — их не берём.
|
||||
protected override bool HandlesGenerated => false;
|
||||
|
||||
// Слоты параллелизма: не запускаем больше _maxParallel транскодов одновременно.
|
||||
using var slots = new SemaphoreSlim(_maxParallel, _maxParallel);
|
||||
var inFlight = new ConcurrentDictionary<Task, byte>();
|
||||
protected override int MaxParallel => Math.Max(1, mediaOptions.Value.MaxParallelTranscodes);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Захватываем и раздаём по слотам всю накопившуюся работу из БД.
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await slots.WaitAsync(stoppingToken);
|
||||
protected override string LoopErrorMessage => "Ошибка цикла обработки медиа";
|
||||
|
||||
// Слот уже захвачен — любой сбой захвата ассета (транзиентная ошибка БД и т.п.)
|
||||
// обязан вернуть слот, иначе после нескольких ошибок семафор исчерпается и
|
||||
// диспетчер зависнет навсегда (сервис формально жив, но ничего не обрабатывает).
|
||||
(Guid Id, string Extension)? claim;
|
||||
try
|
||||
{
|
||||
claim = await ClaimNextAsync(stoppingToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
slots.Release();
|
||||
throw;
|
||||
}
|
||||
protected override void OnStarting() => paths.EnsureDirectories();
|
||||
|
||||
if (claim is not { } job)
|
||||
{
|
||||
slots.Release();
|
||||
break;
|
||||
}
|
||||
protected override ValueTask WaitForWorkAsync(CancellationToken cancellationToken) =>
|
||||
queue.WaitAsync(cancellationToken);
|
||||
|
||||
var task = Task.Run(
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessClaimedAsync(job.Id, job.Extension, stoppingToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
slots.Release();
|
||||
}
|
||||
},
|
||||
CancellationToken.None
|
||||
);
|
||||
inFlight[task] = 0;
|
||||
_ = task.ContinueWith(t => inFlight.TryRemove(t, out _), TaskScheduler.Default);
|
||||
}
|
||||
protected override MediaTranscodeJob ToJob(MediaAsset asset) =>
|
||||
new(asset.Id, asset.OriginalExtension);
|
||||
|
||||
// Работы нет — ждём сигнала о новой либо периодического опроса.
|
||||
using var wake = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
wake.CancelAfter(IdlePoll);
|
||||
try
|
||||
{
|
||||
await queue.WaitAsync(wake.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Тайм-аут опроса — просто перепроверяем БД.
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Ошибка цикла обработки медиа");
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Даём уже запущенным транскодам корректно завершиться (или отмениться) на остановке.
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(inFlight.Keys.ToArray());
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ошибки/отмена отдельных задач уже залогированы внутри ProcessClaimedAsync.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Сброс прерванных рестартом задач (Processing → Pending) на старте.</summary>
|
||||
private async Task ResetInterruptedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||
|
||||
var interrupted = await db
|
||||
.MediaAssets.Where(x =>
|
||||
x.Status == MediaAssetStatus.Processing && x.Source != MediaSource.Generated
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (interrupted.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var asset in interrupted)
|
||||
asset.ResetToPending();
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Атомарно захватывает самый ранний Pending: помечает его Processing и возвращает (id, расширение),
|
||||
/// либо null если работы нет. Вызывается только диспетчером последовательно, поэтому два транскода
|
||||
/// не возьмут один ассет.
|
||||
/// </summary>
|
||||
private async Task<(Guid Id, string Extension)?> ClaimNextAsync(
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||
|
||||
// Generated-ассеты (ТВ-заставки) обслуживает BumperRenderBackgroundService — их не берём.
|
||||
var asset = await db
|
||||
.MediaAssets.Where(x =>
|
||||
x.Status == MediaAssetStatus.Pending && x.Source != MediaSource.Generated
|
||||
)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (asset is null)
|
||||
return null;
|
||||
|
||||
asset.MarkProcessing();
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return (asset.Id, asset.OriginalExtension);
|
||||
}
|
||||
|
||||
/// <summary>Обрабатывает уже захваченный (Processing) ассет: транскод → Ready/Failed.</summary>
|
||||
private async Task ProcessClaimedAsync(
|
||||
Guid assetId,
|
||||
string extension,
|
||||
protected override async Task ProcessAsync(
|
||||
MediaTranscodeJob job,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await processor.ProcessAsync(assetId, extension, cancellationToken);
|
||||
await CompleteAsync(assetId, result, cancellationToken);
|
||||
var result = await processor.ProcessAsync(job.AssetId, job.Extension, cancellationToken);
|
||||
await WithAssetAsync(
|
||||
job.AssetId,
|
||||
asset =>
|
||||
asset.MarkReady(
|
||||
result.Duration,
|
||||
result.SegmentSeconds,
|
||||
result.SegmentCount,
|
||||
result.Width,
|
||||
result.Height,
|
||||
result.VideoCodec,
|
||||
result.AudioCodec,
|
||||
result.RelativePath
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (!_storage.KeepOriginals)
|
||||
DeleteOriginal(assetId, extension);
|
||||
DeleteOriginal(job.AssetId, job.Extension);
|
||||
|
||||
logger.LogInformation(
|
||||
"Ассет {AssetId} обработан: {Segments} сегментов, {Seconds:0.#}с",
|
||||
assetId,
|
||||
job.AssetId,
|
||||
result.SegmentCount,
|
||||
result.Duration.TotalSeconds
|
||||
);
|
||||
@@ -197,56 +81,11 @@ public sealed class MediaProcessingBackgroundService(
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Обработка ассета {AssetId} провалилась", assetId);
|
||||
await FailAsync(assetId, ex.Message, CancellationToken.None);
|
||||
logger.LogError(ex, "Обработка ассета {AssetId} провалилась", job.AssetId);
|
||||
await FailAsync(job.AssetId, ex.Message, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user