Update configuration and enhance media processing: add stream token TTL and timeout settings in .env.example, improve error handling in media endpoints, and refactor command handlers for asynchronous operations. Update documentation to reflect current application state and features.
build / backend (push) Successful in 2m19s
build / frontend (push) Successful in 53s
tests / backend-tests (push) Successful in 2m27s

This commit is contained in:
Leonid Pershin
2026-07-25 23:14:55 +03:00
parent a261e261f0
commit 6c18a9da79
42 changed files with 387 additions and 98 deletions
@@ -26,7 +26,8 @@ public sealed class ClearBumperTemplateAudioCommandHandler(
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
template.ClearAudio();
storage.DeleteAudio(command.TemplateId);
await dbContext.SaveChangesAsync(cancellationToken);
await storage.DeleteAudioAsync(command.TemplateId, cancellationToken);
return Result.Success();
}
}
@@ -28,7 +28,8 @@ public sealed class RemoveBumperTemplateCommandHandler(
return Result.Failure(ChannelErrors.CannotRemoveDefaultBumperTemplate);
channel.RemoveBumperTemplate(command.TemplateId);
storage.DeleteTemplate(command.TemplateId);
await dbContext.SaveChangesAsync(cancellationToken);
await storage.DeleteTemplateAsync(command.TemplateId, cancellationToken);
return Result.Success();
}
}
@@ -5,6 +5,7 @@ namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Синхронно рендерит примеры всех подблоков блока (с примерными названиями шоу). Каждый подблок —
/// в свой ассет-превью (id детерминирован по подблоку). БД не меняет — read-side генерация артефактов.
/// в свой ассет-превью (id детерминирован по подблоку). БД не меняет, но пишет артефакты на диск —
/// поэтому это команда (действие с побочным эффектом), а не запрос.
/// </summary>
public sealed record RenderBumperPreviewQuery(Guid ChannelId, Guid TemplateId) : IQuery<Result>;
public sealed record RenderBumperPreviewCommand(Guid ChannelId, Guid TemplateId) : ICommand<Result>;
@@ -8,14 +8,14 @@ using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class RenderBumperPreviewQueryHandler(
public sealed class RenderBumperPreviewCommandHandler(
IAppDbContext dbContext,
IBumperRenderer renderer,
IBumperTemplateStorage storage,
IImageStore imageStore,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions
) : IQueryHandler<RenderBumperPreviewQuery, Result>
) : ICommandHandler<RenderBumperPreviewCommand, Result>
{
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
@@ -24,7 +24,7 @@ public sealed class RenderBumperPreviewQueryHandler(
private const int DefaultBumperDurationSeconds = 8;
public async Task<Result> Handle(
RenderBumperPreviewQuery query,
RenderBumperPreviewCommand query,
CancellationToken cancellationToken
)
{
@@ -50,6 +50,12 @@ public sealed class ScheduleGenerator(
if (channel is null || !channel.IsEnabled)
return -1;
// Сериализуем генерацию одного канала: фоновый тик и ручная перегенерация не должны читать
// одну точку продолжения и оба дописывать хвост (иначе дубли/перекрытия записей). Advisory-lock
// держится до коммита/отката транзакции ниже; при сбое рендера заставок весь прогон откатится.
await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken);
await dbContext.AcquireChannelLockAsync(channelId, cancellationToken);
var horizonEnd = now.AddDays(_options.HorizonDays);
// Чистим прошлое сверх окна ретеншна.
@@ -77,6 +83,7 @@ public sealed class ScheduleGenerator(
if (startTime >= horizonEnd)
{
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return 0;
}
@@ -130,6 +137,7 @@ public sealed class ScheduleGenerator(
channel.SetNextBumperIndex(result.NextBumperIndex);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return added;
}
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using TeleWave.Domain.Auth;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Images;
@@ -21,4 +22,13 @@ public interface IAppDbContext
DbSet<Image> Images { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
/// <summary>Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч.
/// <c>ExecuteDelete</c> в обход change-tracker), которые должны быть атомарны.</summary>
Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken);
/// <summary>Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате).
/// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией.
/// Вызывать внутри открытой транзакции.</summary>
Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken);
}
@@ -13,10 +13,10 @@ public interface IBumperTemplateStorage
CancellationToken cancellationToken
);
void DeleteAudio(Guid templateId);
Task DeleteAudioAsync(Guid templateId, CancellationToken cancellationToken);
/// <summary>Удалить все файлы блока (при удалении самого блока).</summary>
void DeleteTemplate(Guid templateId);
Task DeleteTemplateAsync(Guid templateId, CancellationToken cancellationToken);
/// <summary>Абсолютный путь к загруженному звуку или null (нет расширения / файл отсутствует).</summary>
string? AudioPath(Guid templateId, string? extension);
@@ -21,7 +21,7 @@ public interface IImageStore
CancellationToken cancellationToken
);
void Delete(Guid imageId, string extension);
Task DeleteAsync(Guid imageId, string extension, CancellationToken cancellationToken);
/// <summary>Абсолютный путь к файлу изображения или null, если файла нет.</summary>
string? ResolvePath(Guid imageId, string extension);
@@ -22,7 +22,7 @@ public interface IMediaStorage
);
/// <summary>Удаляет временный файл загрузки (откат при ошибке до регистрации ассета).</summary>
void DeleteUpload(string uploadToken);
Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken);
/// <summary>
/// Переносит исходник из источника (<c>uploads/</c> для <see cref="MediaSource.Upload"/> либо
@@ -37,5 +37,5 @@ public interface IMediaStorage
);
/// <summary>Удаляет все артефакты ассета: исходник в <c>originals/</c> и каталог сегментов <c>assets/{id}/</c>.</summary>
void DeleteAssetArtifacts(Guid assetId, string extension);
Task DeleteAssetArtifactsAsync(Guid assetId, string extension, CancellationToken cancellationToken);
}
@@ -20,8 +20,13 @@ public sealed class DeleteImageCommandHandler(IAppDbContext dbContext, IImageSto
if (image is null)
return Result.Failure(ImageErrors.NotFound);
storage.Delete(image.Id, image.FileExtension);
// Сначала коммитим удаление в БД, затем файл (best-effort): orphan-файл безопаснее строки БД,
// ссылающейся на уже удалённую картинку.
var imageId = image.Id;
var extension = image.FileExtension;
dbContext.Images.Remove(image);
await dbContext.SaveChangesAsync(cancellationToken);
await storage.DeleteAsync(imageId, extension, cancellationToken);
return Result.Success();
}
}
@@ -17,12 +17,20 @@ public sealed class ClearAllMediaCommandHandler(IAppDbContext dbContext, IMediaS
.MediaAssets.Select(a => new { a.Id, a.OriginalExtension })
.ToListAsync(cancellationToken);
foreach (var asset in assets)
storage.DeleteAssetArtifacts(asset.Id, asset.OriginalExtension);
// Оба ExecuteDelete должны быть атомарны; файлы удаляем только после успешного коммита БД
// (best-effort) — иначе при падении между ними расписание/ассеты рассинхронизируются с ФС.
await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken);
// Расписание ссылается на удаляемые ассеты — чистим его тоже.
await dbContext.ScheduleEntries.ExecuteDeleteAsync(cancellationToken);
var deleted = await dbContext.MediaAssets.ExecuteDeleteAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
foreach (var asset in assets)
await storage.DeleteAssetArtifactsAsync(
asset.Id,
asset.OriginalExtension,
cancellationToken
);
return Result.Success(deleted);
}
@@ -28,9 +28,10 @@ public sealed class DeleteShowMediaCommandHandler(IAppDbContext dbContext, IMedi
.Select(a => new { a.Id, a.OriginalExtension })
.ToListAsync(cancellationToken);
foreach (var asset in assets)
storage.DeleteAssetArtifacts(asset.Id, asset.OriginalExtension);
// Все три операции БД (удаление записей расписания, ассетов, отвязка серий) — атомарно в одной
// транзакции; файлы удаляем только после успешного коммита (best-effort), чтобы при падении не
// остаться с сериями, указывающими на уже удалённые с диска ассеты.
await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken);
await dbContext
.ScheduleEntries.Where(e => assetIds.Contains(e.MediaAssetId))
.ExecuteDeleteAsync(cancellationToken);
@@ -38,9 +39,18 @@ public sealed class DeleteShowMediaCommandHandler(IAppDbContext dbContext, IMedi
.MediaAssets.Where(a => assetIds.Contains(a.Id))
.ExecuteDeleteAsync(cancellationToken);
// Серии шоу теперь указывают на удалённые ассеты — убираем их (сохранится через UnitOfWork).
// Серии шоу теперь указывают на удалённые ассеты — убираем их.
foreach (var episode in episodes)
show.RemoveEpisode(episode.Id);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
foreach (var asset in assets)
await storage.DeleteAssetArtifactsAsync(
asset.Id,
asset.OriginalExtension,
cancellationToken
);
return Result.Success(assets.Count);
}
@@ -21,8 +21,13 @@ public sealed class DeleteMediaAssetCommandHandler(IAppDbContext dbContext, IMed
return Result.Failure(MediaErrors.NotFound);
// TODO(этап 2): запретить удаление, пока ассет используется в расписании/пуле канала.
storage.DeleteAssetArtifacts(asset.Id, asset.OriginalExtension);
// Сначала коммитим удаление в БД, потом чистим файлы (best-effort): orphan-файл на диске
// безопаснее, чем строка БД, ссылающаяся на уже удалённые артефакты.
var extension = asset.OriginalExtension;
var assetId = asset.Id;
dbContext.MediaAssets.Remove(asset);
await dbContext.SaveChangesAsync(cancellationToken);
await storage.DeleteAssetArtifactsAsync(assetId, extension, cancellationToken);
return Result.Success();
}
@@ -27,15 +27,28 @@ public sealed class RegisterMediaAssetCommandHandler(IAppDbContext dbContext, IM
var extension = Path.GetExtension(command.OriginalFileName).ToLowerInvariant();
var asset = MediaAsset.Register(command.OriginalFileName, extension, command.Source);
await storage.PromoteToOriginalAsync(
command.Source,
command.SourceToken,
asset.Id,
extension,
cancellationToken
);
// Сначала фиксируем ассет в БД, затем переносим файл в originals/. Если перенос не удался —
// откатываем регистрацию, чтобы не оставить orphan (либо есть и строка БД, и файл, либо нет ни
// того, ни другого). Обратный порядок оставлял бы перемещённый файл без строки БД при сбое save.
dbContext.MediaAssets.Add(asset);
await dbContext.SaveChangesAsync(cancellationToken);
try
{
await storage.PromoteToOriginalAsync(
command.Source,
command.SourceToken,
asset.Id,
extension,
cancellationToken
);
}
catch
{
dbContext.MediaAssets.Remove(asset);
await dbContext.SaveChangesAsync(cancellationToken);
throw;
}
return Result.Success(asset.Id);
}
}
@@ -9,4 +9,9 @@ public sealed class StreamingOptions
/// <summary>Сколько сегментов держать в скользящем окне live-плейлиста.</summary>
public int LiveWindowSegments { get; init; } = 10;
/// <summary>TTL stream-токена (cookie tw_stream), минуты. Короткий срок ограничивает окно доступа
/// после блокировки/логаута; фронт перевыпускает cookie через /watch, пока идёт просмотр. Блокировка
/// пользователя при этом отражается почти сразу — при очередной перезагрузке live-плейлиста.</summary>
public int StreamTokenMinutes { get; init; } = 30;
}