Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations.
build / backend (push) Successful in 7m40s
build / frontend (push) Failing after 39s
tests / backend-tests (push) Successful in 6m9s

This commit is contained in:
Leonid Pershin
2026-07-26 13:32:13 +03:00
parent c4ef954dea
commit 66040a8841
272 changed files with 27944 additions and 8699 deletions
@@ -0,0 +1,71 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Broadcast;
/// <summary>
/// Обслуживание, которое раньше не делалось вовсе: чистка отрендеренных заставок, на которые больше
/// никто не ссылается. Они копятся при каждой смене пары шоу и при каждой правке блока заставки,
/// а после перехода на длинное хранение расписания их станет заметно больше.
///
/// Идёт по расписанию отдельно от генерации: та держит advisory-лок канала, и подмешивать в неё
/// удаление по всей таблице не нужно.
/// </summary>
public sealed class MaintenanceBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<MaintenanceBackgroundService> logger
) : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromHours(6);
private static readonly TimeSpan StartupDelay = TimeSpan.FromMinutes(2);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await Task.Delay(StartupDelay, stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
using var timer = new PeriodicTimer(Interval);
do
{
try
{
await TickAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка обслуживания");
}
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task TickAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
// Осиротевшей считается заставка, чей ассет не встречается ни в одной записи расписания.
// Границу по времени не ставим: окно хранения расписания уже определяет, что живо.
var removed = await db
.BumperAssets.Where(b =>
!db.ScheduleEntries.Any(e => e.MediaAssetId == b.MediaAssetId)
)
.ExecuteDeleteAsync(cancellationToken);
if (removed > 0)
logger.LogInformation("Обслуживание: удалено осиротевших заставок — {Count}", removed);
}
}
@@ -1,84 +1,96 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Broadcast;
/// <summary>
/// Периодически достраивает расписание каждого включённого канала до горизонта. Расширение хвоста —
/// без удаления существующих записей, поэтому эфир не «дёргается».
/// </summary>
public sealed class SchedulingBackgroundService(
IServiceScopeFactory scopeFactory,
IOptions<SchedulerOptions> options,
ILogger<SchedulingBackgroundService> logger
) : BackgroundService
{
private readonly SchedulerOptions _options = options.Value;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Небольшая задержка на старте — дать примениться миграциям/сидингу.
try
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
using var timer = new PeriodicTimer(
TimeSpan.FromMinutes(Math.Max(1, _options.TickMinutes))
);
do
{
try
{
await TickAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка тика планировщика");
}
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task TickAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var generator = scope.ServiceProvider.GetRequiredService<ScheduleGenerator>();
var channelIds = await db
.Channels.Where(c => c.IsEnabled)
.Select(c => c.Id)
.ToListAsync(cancellationToken);
var now = DateTimeOffset.UtcNow;
foreach (var channelId in channelIds)
{
var added = await generator.GenerateAsync(
channelId,
now,
regenerate: false,
cancellationToken
);
if (added > 0)
logger.LogInformation(
"Канал {ChannelId}: добавлено {Count} записей расписания",
channelId,
added
);
}
}
}
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Planning;
namespace TeleWave.Infrastructure.Broadcast;
/// <summary>
/// Периодически достраивает расписание каждого включённого канала до горизонта. Расширение хвоста —
/// без удаления существующих записей, поэтому эфир не «дёргается».
/// </summary>
public sealed class SchedulingBackgroundService(
IServiceScopeFactory scopeFactory,
IOptions<SchedulerOptions> options,
ILogger<SchedulingBackgroundService> logger
) : BackgroundService
{
private readonly SchedulerOptions _options = options.Value;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Небольшая задержка на старте — дать примениться миграциям/сидингу.
try
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
using var timer = new PeriodicTimer(
TimeSpan.FromMinutes(Math.Max(1, _options.TickMinutes))
);
do
{
try
{
await TickAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка тика планировщика");
}
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task TickAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var generator = scope.ServiceProvider.GetRequiredService<GridScheduleGenerator>();
// Эфир строит только шаблон сетки: канал без шаблона вещать не может и молча пропускается.
var channelIds = await db
.Channels.Where(c => c.IsEnabled && c.TemplateId != null)
.Select(c => c.Id)
.ToListAsync(cancellationToken);
var now = DateTimeOffset.UtcNow;
foreach (var channelId in channelIds)
{
var report = await generator.GenerateAsync(
channelId,
now,
rebuildFuture: false,
cancellationToken
);
if (report.Added > 0)
logger.LogInformation(
"Канал {ChannelId}: добавлено {Count} записей расписания",
channelId,
report.Added
);
foreach (var warning in report.Warnings)
logger.LogWarning(
"Канал {ChannelId}, слот {SlotId}: {Warning} — {Details}",
channelId,
warning.SlotId,
warning.Kind,
warning.Details
);
}
}
}