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:
@@ -0,0 +1,38 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using TeleWave.Infrastructure.Media;
|
||||
|
||||
namespace TeleWave.Api.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Общая проверка файлов нарезки для всех эндпоинтов, отдающих HLS: эфир, превью заставок.
|
||||
/// Имя сегмента сверяется с allowlist, а путь резолвится через <see cref="MediaPathResolver"/>,
|
||||
/// который бросает <see cref="UnauthorizedAccessException"/> на попытку выйти за пределы каталога —
|
||||
/// наружу это должно выглядеть как обычный 404, а не как ошибка сервера.
|
||||
/// </summary>
|
||||
internal static partial class SegmentFiles
|
||||
{
|
||||
[GeneratedRegex(@"^seg\d{1,6}\.ts$")]
|
||||
private static partial Regex SegmentName();
|
||||
|
||||
public static bool IsSegmentName(string file) => SegmentName().IsMatch(file);
|
||||
|
||||
/// <summary>Путь к существующему файлу нарезки, либо null — если имя опасно или файла нет.</summary>
|
||||
public static string? TryResolveExisting(
|
||||
MediaPathResolver paths,
|
||||
Guid assetId,
|
||||
string fileName
|
||||
)
|
||||
{
|
||||
string path;
|
||||
try
|
||||
{
|
||||
path = paths.SegmentPath(assetId, fileName);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Api.Common;
|
||||
using TeleWave.Application.Broadcast;
|
||||
@@ -13,11 +12,6 @@ namespace TeleWave.Api.Endpoints;
|
||||
/// <summary>Эндпоинты ТВ-заставок канала: блоки (стиль/аудио/фон), подблоки и рендер превью.</summary>
|
||||
public static partial class ChannelEndpoints
|
||||
{
|
||||
private static readonly Regex BumperSegmentFileName = new(
|
||||
@"^seg\d{1,6}\.ts$",
|
||||
RegexOptions.Compiled
|
||||
);
|
||||
|
||||
private static async Task<IResult> AddBumperTemplate(
|
||||
Guid id,
|
||||
AddBumperTemplateBody body,
|
||||
@@ -229,16 +223,7 @@ public static partial class ChannelEndpoints
|
||||
)
|
||||
{
|
||||
var previewId = BumperPreview.AssetId(variantId);
|
||||
string indexPath;
|
||||
try
|
||||
{
|
||||
indexPath = paths.SegmentPath(previewId, "index.m3u8");
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
if (!File.Exists(indexPath))
|
||||
if (SegmentFiles.TryResolveExisting(paths, previewId, "index.m3u8") is not { } indexPath)
|
||||
return Results.NotFound();
|
||||
|
||||
var baseUrl =
|
||||
@@ -265,20 +250,11 @@ public static partial class ChannelEndpoints
|
||||
MediaPathResolver paths
|
||||
)
|
||||
{
|
||||
if (!BumperSegmentFileName.IsMatch(file))
|
||||
if (!SegmentFiles.IsSegmentName(file))
|
||||
return Results.NotFound();
|
||||
|
||||
var previewId = BumperPreview.AssetId(variantId);
|
||||
string path;
|
||||
try
|
||||
{
|
||||
path = paths.SegmentPath(previewId, file);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
if (!File.Exists(path))
|
||||
if (SegmentFiles.TryResolveExisting(paths, previewId, file) is not { } path)
|
||||
return Results.NotFound();
|
||||
|
||||
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using LiteCqrs;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using TeleWave.Api.Common;
|
||||
@@ -17,7 +16,6 @@ namespace TeleWave.Api.Endpoints;
|
||||
public static class StreamingEndpoints
|
||||
{
|
||||
private const string StreamCookieName = "tw_stream";
|
||||
private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled);
|
||||
|
||||
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
@@ -143,20 +141,10 @@ public static class StreamingEndpoints
|
||||
{
|
||||
if (tokens.Validate(request.Cookies[StreamCookieName]) is null)
|
||||
return Results.Unauthorized();
|
||||
if (!SegmentFileName.IsMatch(file))
|
||||
if (!SegmentFiles.IsSegmentName(file))
|
||||
return Results.NotFound();
|
||||
|
||||
string path;
|
||||
try
|
||||
{
|
||||
path = paths.SegmentPath(assetId, file);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
if (SegmentFiles.TryResolveExisting(paths, assetId, file) is not { } path)
|
||||
return Results.NotFound();
|
||||
|
||||
response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Broadcast.Scheduling;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>
|
||||
/// Восстанавливает <see cref="BumperRenderSpec"/> по кэш-строке заставки: планировщик сохранил только
|
||||
/// ссылки (канал/блок/подблок/пара шоу), а рендеру нужны названия шоу и абсолютные пути к звуку,
|
||||
/// постеру и фону. Вынесено из фонового рендерера: чтение и сборка — работа слоя приложения,
|
||||
/// воркер лишь крутит ffmpeg.
|
||||
/// </summary>
|
||||
public sealed class BumperSpecLoader(
|
||||
IAppDbContext dbContext,
|
||||
IBumperTemplateStorage bumperStorage,
|
||||
IImageStore imageStore,
|
||||
IOptions<BumperOptions> bumperOptions,
|
||||
IOptions<StreamingOptions> streamingOptions
|
||||
)
|
||||
{
|
||||
private readonly BumperOptions _bumper = bumperOptions.Value;
|
||||
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
||||
|
||||
/// <summary>Спецификация заставки для ассета, либо null если восстановить её уже нельзя.</summary>
|
||||
public async Task<BumperRenderSpec?> LoadAsync(
|
||||
Guid assetId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var cache = await dbContext
|
||||
.BumperAssets.AsNoTracking()
|
||||
.Where(b => b.MediaAssetId == assetId)
|
||||
.OrderByDescending(b => b.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (cache is null)
|
||||
return null;
|
||||
|
||||
var channel = await dbContext
|
||||
.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 dbContext
|
||||
.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);
|
||||
|
||||
// Постер шоу-получателя как фон — только для «Сейчас/Далее».
|
||||
string? posterPath = null;
|
||||
if (variant.Kind == BumperTextKind.NowNext)
|
||||
posterPath = await ResolveShowPosterAsync(cache.ToShowId, cancellationToken);
|
||||
|
||||
var bgPath = await ResolveImagePathAsync(template.BackgroundImageId, cancellationToken);
|
||||
var aligned = BumperDuration.Aligned(
|
||||
BumperDuration.TemplateSeconds(template),
|
||||
_segmentSeconds
|
||||
);
|
||||
|
||||
return BumperSpecFactory.Build(
|
||||
_bumper,
|
||||
channel.BumperFont,
|
||||
template,
|
||||
variant,
|
||||
aligned,
|
||||
names.GetValueOrDefault(cache.FromShowId, "…"),
|
||||
names.GetValueOrDefault(cache.ToShowId, "…"),
|
||||
bumperStorage.AudioPath(template.Id, template.AudioExtension),
|
||||
posterPath,
|
||||
bgPath
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveShowPosterAsync(
|
||||
Guid showId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var posterImageId = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => s.Id == showId && s.PosterImageId != null)
|
||||
.Select(s => s.PosterImageId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return await ResolveImagePathAsync(posterImageId, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveImagePathAsync(
|
||||
Guid? imageId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (imageId is not { } id)
|
||||
return null;
|
||||
|
||||
var ext = await dbContext
|
||||
.Images.AsNoTracking()
|
||||
.Where(i => i.Id == id)
|
||||
.Select(i => i.FileExtension)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return ext is null ? null : imageStore.ResolvePath(id, ext);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,12 @@ using FluentValidation;
|
||||
using LiteCqrs.Behaviors;
|
||||
using LiteCqrs.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Common.Behaviors;
|
||||
using TeleWave.Application.Library.Genres;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Application.Programming.Planning;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
|
||||
namespace TeleWave.Application;
|
||||
|
||||
@@ -26,6 +31,19 @@ public static class DependencyInjection
|
||||
|
||||
RegisterClosedGeneric(services, assembly, typeof(IValidator<>));
|
||||
|
||||
// Сервисы самого слоя приложения — не хендлеры, а общие для них помощники. Регистрируются
|
||||
// здесь, а не в Infrastructure: тот слой не должен знать внутреннего устройства Application.
|
||||
services.AddScoped<BumperSpecLoader>();
|
||||
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>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,6 @@ public static class GroupErrors
|
||||
"Шоу или коллекция не найдены."
|
||||
);
|
||||
|
||||
public static readonly Error ElementAlreadyAdded = Error.Conflict(
|
||||
"Groups.ElementAlreadyAdded",
|
||||
"Этот элемент уже входит в группу."
|
||||
);
|
||||
|
||||
public static readonly Error FilterNotSet = Error.Validation(
|
||||
"Groups.FilterNotSet",
|
||||
"У группы не задано правило набора."
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Junctions;
|
||||
|
||||
public sealed class AddJunctionElementCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<AddJunctionElementCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
AddJunctionElementCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var junction = await JunctionLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.JunctionId,
|
||||
cancellationToken
|
||||
);
|
||||
if (junction is null)
|
||||
return Result.Failure<Guid>(TemplateErrors.JunctionNotFound);
|
||||
|
||||
var element = junction.AddElement(command.Kind);
|
||||
await JunctionLoader.MarkTemplateChangedAsync(dbContext, junction, cancellationToken);
|
||||
return Result.Success(element.Id);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Junctions;
|
||||
|
||||
public sealed class CreateJunctionCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CreateJunctionCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
CreateJunctionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!await dbContext.Channels.AnyAsync(c => c.Id == command.ChannelId, cancellationToken))
|
||||
return Result.Failure<Guid>(ChannelErrors.NotFound);
|
||||
|
||||
var junction = JunctionTemplate.Create(command.ChannelId, command.Name);
|
||||
dbContext.JunctionTemplates.Add(junction);
|
||||
return Result.Success(junction.Id);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Junctions;
|
||||
|
||||
public sealed class DeleteJunctionCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<DeleteJunctionCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
DeleteJunctionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var junction = await JunctionLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.JunctionId,
|
||||
cancellationToken
|
||||
);
|
||||
if (junction is null)
|
||||
return Result.Failure(TemplateErrors.JunctionNotFound);
|
||||
|
||||
// Слот, ссылающийся на удалённый стык, молча остался бы без врезок — проверяем заранее.
|
||||
var used = await dbContext.Slots.AnyAsync(
|
||||
s => s.JunctionBetweenId == junction.Id || s.JunctionAfterId == junction.Id,
|
||||
cancellationToken
|
||||
);
|
||||
if (used)
|
||||
return Result.Failure(TemplateErrors.JunctionInUse);
|
||||
|
||||
dbContext.JunctionTemplates.Remove(junction);
|
||||
return await JunctionLoader.MarkTemplateChangedAsync(
|
||||
dbContext,
|
||||
junction,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Junctions;
|
||||
|
||||
public sealed class ListJunctionsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListJunctionsQuery, IReadOnlyList<JunctionTemplateDto>>
|
||||
{
|
||||
public async Task<IReadOnlyList<JunctionTemplateDto>> Handle(
|
||||
ListJunctionsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var junctions = await dbContext
|
||||
.JunctionTemplates.AsNoTracking()
|
||||
.Include(j => j.Elements)
|
||||
.Where(j => j.ChannelId == query.ChannelId)
|
||||
.OrderBy(j => j.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Имена групп и блоков заставок резолвим одним проходом — редактор показывает их сразу.
|
||||
var groupIds = junctions
|
||||
.SelectMany(j => j.Elements)
|
||||
.Select(e => e.GroupId)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var groupNames = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Where(g => groupIds.Contains(g.Id))
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
|
||||
|
||||
var bumperNames = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.Where(c => c.Id == query.ChannelId)
|
||||
.SelectMany(c => c.BumperTemplates)
|
||||
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
|
||||
|
||||
return junctions
|
||||
.Select(j => new JunctionTemplateDto(
|
||||
j.Id,
|
||||
j.Name,
|
||||
j.Elements.OrderBy(e => e.Position)
|
||||
.Select(e => new JunctionElementDto(
|
||||
e.Id,
|
||||
e.Position,
|
||||
e.Kind,
|
||||
e.GroupId,
|
||||
e.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
|
||||
? gname
|
||||
: null,
|
||||
e.BumperTemplateId,
|
||||
e.BumperTemplateId is { } bid && bumperNames.TryGetValue(bid, out var bname)
|
||||
? bname
|
||||
: null,
|
||||
e.AmountMode,
|
||||
e.AmountValue,
|
||||
e.IsRequired,
|
||||
JunctionConditions.FromJson(e.ConditionsJson)
|
||||
))
|
||||
.ToList()
|
||||
))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CreateJunctionCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CreateJunctionCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
CreateJunctionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!await dbContext.Channels.AnyAsync(c => c.Id == command.ChannelId, cancellationToken))
|
||||
return Result.Failure<Guid>(ChannelErrors.NotFound);
|
||||
|
||||
var junction = JunctionTemplate.Create(command.ChannelId, command.Name);
|
||||
dbContext.JunctionTemplates.Add(junction);
|
||||
return Result.Success(junction.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RenameJunctionCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<RenameJunctionCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
RenameJunctionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var junction = await JunctionLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.JunctionId,
|
||||
cancellationToken
|
||||
);
|
||||
if (junction is null)
|
||||
return Result.Failure(TemplateErrors.JunctionNotFound);
|
||||
|
||||
junction.Rename(command.Name);
|
||||
return await MarkTemplateChangedAsync(dbContext, junction, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Правка стыка — тоже правка правил эфира: шаблон канала помечается изменённым.</summary>
|
||||
internal static async Task<Result> MarkTemplateChangedAsync(
|
||||
IAppDbContext dbContext,
|
||||
JunctionTemplate junction,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
|
||||
t => t.ChannelId == junction.ChannelId,
|
||||
cancellationToken
|
||||
);
|
||||
template?.MarkChanged();
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DeleteJunctionCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<DeleteJunctionCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
DeleteJunctionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var junction = await JunctionLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.JunctionId,
|
||||
cancellationToken
|
||||
);
|
||||
if (junction is null)
|
||||
return Result.Failure(TemplateErrors.JunctionNotFound);
|
||||
|
||||
// Слот, ссылающийся на удалённый стык, молча остался бы без врезок — проверяем заранее.
|
||||
var used = await dbContext.Slots.AnyAsync(
|
||||
s => s.JunctionBetweenId == junction.Id || s.JunctionAfterId == junction.Id,
|
||||
cancellationToken
|
||||
);
|
||||
if (used)
|
||||
return Result.Failure(TemplateErrors.JunctionInUse);
|
||||
|
||||
dbContext.JunctionTemplates.Remove(junction);
|
||||
return await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
|
||||
dbContext,
|
||||
junction,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AddJunctionElementCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<AddJunctionElementCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
AddJunctionElementCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var junction = await JunctionLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.JunctionId,
|
||||
cancellationToken
|
||||
);
|
||||
if (junction is null)
|
||||
return Result.Failure<Guid>(TemplateErrors.JunctionNotFound);
|
||||
|
||||
var element = junction.AddElement(command.Kind);
|
||||
await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
|
||||
dbContext,
|
||||
junction,
|
||||
cancellationToken
|
||||
);
|
||||
return Result.Success(element.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateJunctionElementCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateJunctionElementCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateJunctionElementCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var junction = await JunctionLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.JunctionId,
|
||||
cancellationToken
|
||||
);
|
||||
var element = junction?.FindElement(command.ElementId);
|
||||
if (junction is null || element is null)
|
||||
return Result.Failure(TemplateErrors.JunctionElementNotFound);
|
||||
|
||||
var input = command.Input;
|
||||
|
||||
if (input.Kind == JunctionElementKind.Bumper)
|
||||
{
|
||||
var known = await dbContext
|
||||
.Channels.Where(c => c.Id == junction.ChannelId)
|
||||
.SelectMany(c => c.BumperTemplates)
|
||||
.AnyAsync(t => t.Id == input.BumperTemplateId, cancellationToken);
|
||||
if (!known)
|
||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (input.GroupId is not { } groupId)
|
||||
return Result.Failure(TemplateErrors.JunctionGroupRequired);
|
||||
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
|
||||
return Result.Failure(TemplateErrors.GroupNotFound);
|
||||
}
|
||||
|
||||
element.Update(
|
||||
input.Kind,
|
||||
input.GroupId,
|
||||
input.BumperTemplateId,
|
||||
input.AmountMode,
|
||||
input.AmountValue,
|
||||
input.IsRequired,
|
||||
input.Conditions?.ToJson()
|
||||
);
|
||||
|
||||
return await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
|
||||
dbContext,
|
||||
junction,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RemoveJunctionElementCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<RemoveJunctionElementCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
RemoveJunctionElementCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var junction = await JunctionLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.JunctionId,
|
||||
cancellationToken
|
||||
);
|
||||
if (junction is null || !junction.RemoveElement(command.ElementId))
|
||||
return Result.Failure(TemplateErrors.JunctionElementNotFound);
|
||||
|
||||
return await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
|
||||
dbContext,
|
||||
junction,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ReorderJunctionCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<ReorderJunctionCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
ReorderJunctionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var junction = await JunctionLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.JunctionId,
|
||||
cancellationToken
|
||||
);
|
||||
if (junction is null)
|
||||
return Result.Failure(TemplateErrors.JunctionNotFound);
|
||||
|
||||
junction.Reorder(command.ElementIdsInOrder);
|
||||
return await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
|
||||
dbContext,
|
||||
junction,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class JunctionLoader
|
||||
{
|
||||
public static Task<JunctionTemplate?> LoadAsync(
|
||||
IAppDbContext dbContext,
|
||||
Guid junctionId,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
dbContext
|
||||
.JunctionTemplates.Include(j => j.Elements)
|
||||
.FirstOrDefaultAsync(j => j.Id == junctionId, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Junctions;
|
||||
|
||||
/// <summary>Общее для всех команд стыка: загрузка шаблона и отметка правил эфира изменёнными.</summary>
|
||||
internal static class JunctionLoader
|
||||
{
|
||||
public static Task<JunctionTemplate?> LoadAsync(
|
||||
IAppDbContext dbContext,
|
||||
Guid junctionId,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
dbContext
|
||||
.JunctionTemplates.Include(j => j.Elements)
|
||||
.FirstOrDefaultAsync(j => j.Id == junctionId, cancellationToken);
|
||||
|
||||
/// <summary>Правка стыка — тоже правка правил эфира: шаблон канала помечается изменённым.</summary>
|
||||
public static async Task<Result> MarkTemplateChangedAsync(
|
||||
IAppDbContext dbContext,
|
||||
JunctionTemplate junction,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
|
||||
t => t.ChannelId == junction.ChannelId,
|
||||
cancellationToken
|
||||
);
|
||||
template?.MarkChanged();
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Junctions;
|
||||
|
||||
public sealed class ListJunctionsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListJunctionsQuery, IReadOnlyList<JunctionTemplateDto>>
|
||||
{
|
||||
public async Task<IReadOnlyList<JunctionTemplateDto>> Handle(
|
||||
ListJunctionsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var junctions = await dbContext
|
||||
.JunctionTemplates.AsNoTracking()
|
||||
.Include(j => j.Elements)
|
||||
.Where(j => j.ChannelId == query.ChannelId)
|
||||
.OrderBy(j => j.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Имена групп и блоков заставок резолвим одним проходом — редактор показывает их сразу.
|
||||
var groupIds = junctions
|
||||
.SelectMany(j => j.Elements)
|
||||
.Select(e => e.GroupId)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var groupNames = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Where(g => groupIds.Contains(g.Id))
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
|
||||
|
||||
var bumperNames = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.Where(c => c.Id == query.ChannelId)
|
||||
.SelectMany(c => c.BumperTemplates)
|
||||
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
|
||||
|
||||
return junctions
|
||||
.Select(j => new JunctionTemplateDto(
|
||||
j.Id,
|
||||
j.Name,
|
||||
j.Elements.OrderBy(e => e.Position)
|
||||
.Select(e => new JunctionElementDto(
|
||||
e.Id,
|
||||
e.Position,
|
||||
e.Kind,
|
||||
e.GroupId,
|
||||
e.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
|
||||
? gname
|
||||
: null,
|
||||
e.BumperTemplateId,
|
||||
e.BumperTemplateId is { } bid && bumperNames.TryGetValue(bid, out var bname)
|
||||
? bname
|
||||
: null,
|
||||
e.AmountMode,
|
||||
e.AmountValue,
|
||||
e.IsRequired,
|
||||
JunctionConditions.FromJson(e.ConditionsJson)
|
||||
))
|
||||
.ToList()
|
||||
))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Junctions;
|
||||
|
||||
public sealed class RemoveJunctionElementCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<RemoveJunctionElementCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
RemoveJunctionElementCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var junction = await JunctionLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.JunctionId,
|
||||
cancellationToken
|
||||
);
|
||||
if (junction is null || !junction.RemoveElement(command.ElementId))
|
||||
return Result.Failure(TemplateErrors.JunctionElementNotFound);
|
||||
|
||||
return await JunctionLoader.MarkTemplateChangedAsync(
|
||||
dbContext,
|
||||
junction,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Junctions;
|
||||
|
||||
public sealed class RenameJunctionCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<RenameJunctionCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
RenameJunctionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var junction = await JunctionLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.JunctionId,
|
||||
cancellationToken
|
||||
);
|
||||
if (junction is null)
|
||||
return Result.Failure(TemplateErrors.JunctionNotFound);
|
||||
|
||||
junction.Rename(command.Name);
|
||||
return await JunctionLoader.MarkTemplateChangedAsync(
|
||||
dbContext,
|
||||
junction,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Junctions;
|
||||
|
||||
public sealed class ReorderJunctionCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<ReorderJunctionCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
ReorderJunctionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var junction = await JunctionLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.JunctionId,
|
||||
cancellationToken
|
||||
);
|
||||
if (junction is null)
|
||||
return Result.Failure(TemplateErrors.JunctionNotFound);
|
||||
|
||||
junction.Reorder(command.ElementIdsInOrder);
|
||||
return await JunctionLoader.MarkTemplateChangedAsync(
|
||||
dbContext,
|
||||
junction,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Junctions;
|
||||
|
||||
public sealed class UpdateJunctionElementCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateJunctionElementCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateJunctionElementCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var junction = await JunctionLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.JunctionId,
|
||||
cancellationToken
|
||||
);
|
||||
var element = junction?.FindElement(command.ElementId);
|
||||
if (junction is null || element is null)
|
||||
return Result.Failure(TemplateErrors.JunctionElementNotFound);
|
||||
|
||||
var input = command.Input;
|
||||
|
||||
if (input.Kind == JunctionElementKind.Bumper)
|
||||
{
|
||||
var known = await dbContext
|
||||
.Channels.Where(c => c.Id == junction.ChannelId)
|
||||
.SelectMany(c => c.BumperTemplates)
|
||||
.AnyAsync(t => t.Id == input.BumperTemplateId, cancellationToken);
|
||||
if (!known)
|
||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (input.GroupId is not { } groupId)
|
||||
return Result.Failure(TemplateErrors.JunctionGroupRequired);
|
||||
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
|
||||
return Result.Failure(TemplateErrors.GroupNotFound);
|
||||
}
|
||||
|
||||
element.Update(
|
||||
input.Kind,
|
||||
input.GroupId,
|
||||
input.BumperTemplateId,
|
||||
input.AmountMode,
|
||||
input.AmountValue,
|
||||
input.IsRequired,
|
||||
input.Conditions?.ToJson()
|
||||
);
|
||||
|
||||
return await JunctionLoader.MarkTemplateChangedAsync(
|
||||
dbContext,
|
||||
junction,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -281,9 +281,13 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
|
||||
.Select(i => new { i.CollectionId, i.ShowId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Список id собираем до запроса: проекция по материализованной коллекции внутри дерева
|
||||
// выражений заставляет EF пересобирать её на каждый вызов.
|
||||
var neededShowIds = showIds.Concat(partsByCollection.Select(p => p.ShowId)).Distinct().ToList();
|
||||
|
||||
var audiences = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id) || partsByCollection.Select(p => p.ShowId).Contains(s.Id))
|
||||
.Where(s => neededShowIds.Contains(s.Id))
|
||||
.Select(s => new { s.Id, s.Audience })
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);
|
||||
|
||||
|
||||
@@ -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