Refactor bumper template background management: rename background upload endpoint to reflect new functionality, update related command and handler to use image ID instead of extension, and adjust data models to support image references. Remove obsolete background handling code and enhance UI components for improved image selection and display.

This commit is contained in:
Leonid Pershin
2026-07-25 11:56:36 +03:00
parent a970eae7d2
commit 7efd616c29
35 changed files with 1227 additions and 279 deletions
@@ -5,10 +5,8 @@ using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class ClearBumperTemplateBackgroundCommandHandler(
IAppDbContext dbContext,
IBumperTemplateStorage storage
) : ICommandHandler<ClearBumperTemplateBackgroundCommand, Result>
public sealed class ClearBumperTemplateBackgroundCommandHandler(IAppDbContext dbContext)
: ICommandHandler<ClearBumperTemplateBackgroundCommand, Result>
{
public async Task<Result> Handle(
ClearBumperTemplateBackgroundCommand command,
@@ -25,8 +23,8 @@ public sealed class ClearBumperTemplateBackgroundCommandHandler(
if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
// Отвязываем фон; сама картинка остаётся в галерее.
template.ClearBackgroundImage();
storage.DeleteBackground(command.TemplateId);
return Result.Success();
}
}
@@ -12,6 +12,7 @@ public sealed class RenderBumperPreviewQueryHandler(
IAppDbContext dbContext,
IBumperRenderer renderer,
IBumperTemplateStorage storage,
IImageStore imageStore,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions
) : IQueryHandler<RenderBumperPreviewQuery, Result<Guid>>
@@ -40,6 +41,18 @@ public sealed class RenderBumperPreviewQueryHandler(
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
// Фон блока — из общего реестра по id.
string? backgroundPath = null;
if (template.BackgroundImageId is { } bgId)
{
var bgExt = await dbContext.Images.AsNoTracking()
.Where(i => i.Id == bgId)
.Select(i => i.FileExtension)
.FirstOrDefaultAsync(cancellationToken);
if (bgExt is not null)
backgroundPath = imageStore.ResolvePath(bgId, bgExt);
}
var seconds =
template.AudioDurationSeconds is { } d and > 0 ? d : DefaultBumperDurationSeconds;
var aligned = (int)(
@@ -59,7 +72,7 @@ public sealed class RenderBumperPreviewQueryHandler(
fromName,
channel.BumperNextLabel,
toName,
storage.BackgroundPath(template.Id, template.BackgroundImageExtension),
backgroundPath,
storage.AudioPath(template.Id, template.AudioExtension),
// Постер зависит от конкретного «следующего» шоу — в превью не подставляем.
null
@@ -3,9 +3,9 @@ using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Отметить загруженную фон-картинку блока (расширение — с точкой).</summary>
/// <summary>Привязать фон-картинку блока по ссылке на изображение из реестра (галерея).</summary>
public sealed record SetBumperTemplateBackgroundCommand(
Guid ChannelId,
Guid TemplateId,
string Extension
Guid ImageId
) : ICommand<Result>;
@@ -23,7 +23,7 @@ public sealed class SetBumperTemplateBackgroundCommandHandler(IAppDbContext dbCo
if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
template.SetBackgroundImage(command.Extension);
template.SetBackgroundImage(command.ImageId);
return Result.Success();
}
}
@@ -47,7 +47,7 @@ public sealed record BumperTemplateDto(
string BackgroundColor2,
string AccentColor,
string TextColor,
bool HasBackground,
Guid? BackgroundImageId,
bool HasAudio,
double? AudioDurationSeconds
);
@@ -75,7 +75,7 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
t.BackgroundColor2,
t.AccentColor,
t.TextColor,
t.BackgroundImageExtension is not null,
t.BackgroundImageId,
t.AudioExtension is not null,
t.AudioDurationSeconds
))
@@ -216,6 +216,25 @@ public sealed class ScheduleGenerator(
)
posterByShow[p.Id] = (p.ImageId, abs);
// Фон-картинки блоков (из реестра) — абсолютные пути по id.
var bgImageIds = channel.BumperTemplates
.Where(t => t.BackgroundImageId != null)
.Select(t => t.BackgroundImageId!.Value)
.Distinct()
.ToList();
var bgExtById = await dbContext.Images.AsNoTracking()
.Where(i => bgImageIds.Contains(i.Id))
.Select(i => new { i.Id, i.FileExtension })
.ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken);
var bgByTemplate = new Dictionary<Guid, string>();
foreach (var t in channel.BumperTemplates)
if (
t.BackgroundImageId is { } bgId
&& bgExtById.TryGetValue(bgId, out var bgExt)
&& imageStore.ResolvePath(bgId, bgExt) is { } bgAbs
)
bgByTemplate[t.Id] = bgAbs;
// Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить).
var cached = await dbContext.BumperAssets.AsNoTracking()
.Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId))
@@ -245,6 +264,7 @@ public sealed class ScheduleGenerator(
var poster = posterByShow.TryGetValue(combo.To, out var pr) ? pr : default;
var posterToken = poster.ImageId == Guid.Empty ? "-" : poster.ImageId.ToString();
var posterAbs = poster.ImageId == Guid.Empty ? null : poster.AbsPath;
var bgAbs = bgByTemplate.GetValueOrDefault(template.Id);
var aligned = AlignedDurationSeconds(TemplateDurationSeconds(template));
var signature = ComputeSignature(channel, template, fromName, toName, aligned, posterToken);
@@ -272,6 +292,7 @@ public sealed class ScheduleGenerator(
aligned,
signature,
posterAbs,
bgAbs,
cancellationToken
);
result[combo] = assetId;
@@ -300,14 +321,22 @@ public sealed class ScheduleGenerator(
int alignedDurationSeconds,
string signature,
string? posterAbsolutePath,
string? backgroundAbsolutePath,
CancellationToken cancellationToken
)
{
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
var posterAbs = posterAbsolutePath;
var render = await bumperRenderer.RenderAsync(
asset.Id,
BuildSpec(channel, template, alignedDurationSeconds, fromName, toName, posterAbs),
BuildSpec(
channel,
template,
alignedDurationSeconds,
fromName,
toName,
posterAbsolutePath,
backgroundAbsolutePath
),
cancellationToken
);
@@ -335,7 +364,8 @@ public sealed class ScheduleGenerator(
int alignedDurationSeconds,
string fromName,
string toName,
string? posterAbsolutePath
string? posterAbsolutePath,
string? backgroundAbsolutePath
) =>
new(
alignedDurationSeconds,
@@ -350,7 +380,7 @@ public sealed class ScheduleGenerator(
fromName,
channel.BumperNextLabel,
toName,
bumperStorage.BackgroundPath(template.Id, template.BackgroundImageExtension),
backgroundAbsolutePath,
bumperStorage.AudioPath(template.Id, template.AudioExtension),
posterAbsolutePath
);
@@ -397,7 +427,7 @@ public sealed class ScheduleGenerator(
template.AccentColor,
template.TextColor,
template.Revision,
template.BackgroundImageExtension ?? "-",
template.BackgroundImageId?.ToString() ?? "-",
template.AudioExtension ?? "-",
fromName,
toName,
@@ -1,8 +1,8 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>
/// Хранилище сырых файлов блоков заставок (звук и фон-картинка) под bumpers/{templateId}. В отличие
/// от обычных ассетов эти файлы НЕ режутся на HLS — они подаются как входы в рендер заставки.
/// Хранилище звука блоков заставок под bumpers/{templateId}. Фон-картинка блока живёт в общем реестре
/// изображений (см. IImageStore). Звук НЕ режется на HLS — подаётся входом в рендер заставки.
/// </summary>
public interface IBumperTemplateStorage
{
@@ -13,22 +13,11 @@ public interface IBumperTemplateStorage
CancellationToken cancellationToken
);
Task SaveBackgroundAsync(
Guid templateId,
string extension,
Stream content,
CancellationToken cancellationToken
);
void DeleteAudio(Guid templateId);
void DeleteBackground(Guid templateId);
/// <summary>Удалить все файлы блока (при удалении самого блока).</summary>
void DeleteTemplate(Guid templateId);
/// <summary>Абсолютный путь к загруженному звуку или null (нет расширения / файл отсутствует).</summary>
string? AudioPath(Guid templateId, string? extension);
/// <summary>Абсолютный путь к загруженной фон-картинке или null.</summary>
string? BackgroundPath(Guid templateId, string? extension);
}
@@ -1,20 +0,0 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>
/// Локальное хранилище картинок метаданных (постеры/кадры) под metadata/ в корне хранилища. Скачивает
/// изображения к себе, чтобы не зависеть от внешнего CDN на этапе показа.
/// </summary>
public interface IMetadataImageStore
{
/// <summary>Скачивает кадр серии по URL. Возвращает относительный путь или null при ошибке.</summary>
Task<string?> DownloadEpisodeStillAsync(
Guid episodeId,
string url,
CancellationToken cancellationToken
);
void DeleteEpisodeImages(Guid episodeId);
/// <summary>Абсолютный путь к файлу по относительному (с защитой от traversal) или null, если вне корня.</summary>
string? ResolveAbsolutePath(string relativePath);
}
@@ -44,7 +44,7 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
e.Episode,
e.Title,
e.Overview,
e.StillPath is not null,
e.StillImageId,
e.AirDate
);
})
@@ -25,7 +25,7 @@ public sealed record EpisodeDto(
int? Episode,
string? Title,
string? Overview,
bool HasStill,
Guid? StillImageId,
DateOnly? AirDate
);
@@ -3,13 +3,15 @@ using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Library;
using TeleWave.Domain.Images;
namespace TeleWave.Application.Metadata.RefreshEpisodes;
public sealed class RefreshShowEpisodesMetadataCommandHandler(
IAppDbContext dbContext,
IMetadataProviderResolver resolver,
IMetadataImageStore imageStore
IImageDownloader downloader,
IImageStore imageStore
) : ICommandHandler<RefreshShowEpisodesMetadataCommand, Result<int>>
{
public async Task<Result<int>> Handle(
@@ -66,15 +68,29 @@ public sealed class RefreshShowEpisodesMetadataCommandHandler(
if (meta is null)
continue;
string? stillPath = null;
Guid? stillImageId = null;
if (!string.IsNullOrEmpty(meta.StillUrl))
stillPath = await imageStore.DownloadEpisodeStillAsync(
episode.Id,
meta.StillUrl,
cancellationToken
);
{
var downloaded = await downloader.DownloadAsync(meta.StillUrl, cancellationToken);
if (downloaded is not null)
{
var image = Image.Create(
ImageCategory.EpisodeStill,
downloaded.Extension,
meta.Title
);
dbContext.Images.Add(image);
await imageStore.SaveAsync(
image.Id,
downloaded.Extension,
downloaded.Content,
cancellationToken
);
stillImageId = image.Id;
}
}
episode.ApplyMetadata(meta.Title, meta.Overview, stillPath, meta.AirDate);
episode.ApplyMetadata(meta.Title, meta.Overview, stillImageId, meta.AirDate);
updated++;
}
@@ -56,7 +56,7 @@ public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext)
e.Id,
e.Title,
e.Overview,
e.StillPath,
e.StillImageId,
})
.ToListAsync(cancellationToken);
var episodeByKey = episodes
@@ -82,7 +82,7 @@ public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext)
episode?.Id,
episode?.Title,
episode?.Overview,
episode?.StillPath is not null
episode?.StillImageId
);
})
.ToList();
@@ -22,7 +22,7 @@ public sealed record PublicEpgEntryDto(
Guid? EpisodeId,
string? EpisodeTitle,
string? EpisodeOverview,
bool EpisodeHasStill
Guid? EpisodeStillImageId
);
public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity);