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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user