Implement image management enhancements: add functionality to relocate legacy images during startup, update image handling in show metadata, and refactor related API endpoints to utilize the new image storage system. Adjust database schema to support image references and update UI components for improved image selection and display.

This commit is contained in:
Leonid Pershin
2026-07-25 11:43:17 +03:00
parent 149cd153b9
commit a970eae7d2
30 changed files with 1231 additions and 172 deletions
@@ -24,7 +24,7 @@ public sealed class ScheduleGenerator(
IRandomSource random,
IBumperRenderer bumperRenderer,
IBumperTemplateStorage bumperStorage,
IMetadataImageStore metadataImages,
IImageStore imageStore,
IOptions<SchedulerOptions> options,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions,
@@ -196,12 +196,25 @@ public sealed class ScheduleGenerator(
var fromIds = combos.Select(c => c.From).Distinct().ToList();
var toIds = combos.Select(c => c.To).Distinct().ToList();
// Постеры шоу-получателей — как фон заставки (если у блока нет своей фон-картинки).
// Постеры шоу-получателей (из реестра изображений) — как фон заставки, если у блока нет
// своей фон-картинки. Резолвим id постера → расширение → абсолютный путь.
var showIds = fromIds.Concat(toIds).Distinct().ToList();
var posterByShow = await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id) && s.PosterPath != null)
.Select(s => new { s.Id, s.PosterPath })
.ToDictionaryAsync(s => s.Id, s => s.PosterPath!, cancellationToken);
var posterShows = await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id) && s.PosterImageId != null)
.Select(s => new { s.Id, ImageId = s.PosterImageId!.Value })
.ToListAsync(cancellationToken);
var posterImageIds = posterShows.Select(p => p.ImageId).Distinct().ToList();
var posterExtById = await dbContext.Images.AsNoTracking()
.Where(i => posterImageIds.Contains(i.Id))
.Select(i => new { i.Id, i.FileExtension })
.ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken);
var posterByShow = new Dictionary<Guid, (Guid ImageId, string AbsPath)>();
foreach (var p in posterShows)
if (
posterExtById.TryGetValue(p.ImageId, out var ext)
&& imageStore.ResolvePath(p.ImageId, ext) is { } abs
)
posterByShow[p.Id] = (p.ImageId, abs);
// Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить).
var cached = await dbContext.BumperAssets.AsNoTracking()
@@ -229,9 +242,11 @@ public sealed class ScheduleGenerator(
var fromName = showNames.GetValueOrDefault(combo.From, "…");
var toName = showNames.GetValueOrDefault(combo.To, "…");
var toPosterRel = posterByShow.GetValueOrDefault(combo.To);
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 aligned = AlignedDurationSeconds(TemplateDurationSeconds(template));
var signature = ComputeSignature(channel, template, fromName, toName, aligned, toPosterRel ?? "-");
var signature = ComputeSignature(channel, template, fromName, toName, aligned, posterToken);
var hit = cached.FirstOrDefault(c =>
c.FromShowId == combo.From
@@ -256,7 +271,7 @@ public sealed class ScheduleGenerator(
toName,
aligned,
signature,
toPosterRel,
posterAbs,
cancellationToken
);
result[combo] = assetId;
@@ -284,14 +299,12 @@ public sealed class ScheduleGenerator(
string toName,
int alignedDurationSeconds,
string signature,
string? toPosterRelative,
string? posterAbsolutePath,
CancellationToken cancellationToken
)
{
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
var posterAbs = toPosterRelative is null
? null
: metadataImages.ResolveAbsolutePath(toPosterRelative);
var posterAbs = posterAbsolutePath;
var render = await bumperRenderer.RenderAsync(
asset.Id,
BuildSpec(channel, template, alignedDurationSeconds, fromName, toName, posterAbs),
@@ -0,0 +1,10 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>Скачанное изображение: содержимое + расширение (с точкой, нижний регистр).</summary>
public sealed record DownloadedImage(byte[] Content, string Extension);
/// <summary>Скачивание изображения по URL (например постера/кадра из метаданных) для реестра.</summary>
public interface IImageDownloader
{
Task<DownloadedImage?> DownloadAsync(string url, CancellationToken cancellationToken);
}
@@ -6,23 +6,6 @@ namespace TeleWave.Application.Common.Interfaces;
/// </summary>
public interface IMetadataImageStore
{
/// <summary>Скачивает постер по URL и сохраняет для шоу. Возвращает относительный путь или null при ошибке.</summary>
Task<string?> DownloadShowPosterAsync(
Guid showId,
string url,
CancellationToken cancellationToken
);
/// <summary>Сохраняет загруженный вручную постер шоу. Возвращает относительный путь.</summary>
Task<string> SaveShowPosterAsync(
Guid showId,
string extension,
Stream content,
CancellationToken cancellationToken
);
void DeleteShowImages(Guid showId);
/// <summary>Скачивает кадр серии по URL. Возвращает относительный путь или null при ошибке.</summary>
Task<string?> DownloadEpisodeStillAsync(
Guid episodeId,
@@ -60,7 +60,7 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
show.MetadataProvider,
show.MetadataExternalId,
show.Year,
show.PosterPath is not null,
show.PosterImageId,
episodeDtos
)
);
@@ -39,7 +39,7 @@ public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
s.Episodes.Count,
seasons,
s.Year,
s.PosterPath is not null,
s.PosterImageId is not null,
s.CreatedAt
);
})
@@ -38,6 +38,6 @@ public sealed record ShowDto(
string? MetadataProvider,
string? MetadataExternalId,
int? Year,
bool HasPoster,
Guid? PosterImageId,
IReadOnlyList<EpisodeDto> Episodes
);
@@ -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.ApplyShowMetadata;
public sealed class ApplyShowMetadataCommandHandler(
IAppDbContext dbContext,
IMetadataProviderResolver resolver,
IMetadataImageStore imageStore
IImageDownloader downloader,
IImageStore imageStore
) : ICommandHandler<ApplyShowMetadataCommand, Result>
{
public async Task<Result> Handle(
@@ -32,15 +34,26 @@ public sealed class ApplyShowMetadataCommandHandler(
if (meta is null)
return Result.Failure(MetadataErrors.NotFound);
string? posterPath = null;
// Постер скачиваем и регистрируем в общем реестре изображений (галерея).
Guid? posterImageId = null;
if (!string.IsNullOrEmpty(meta.PosterUrl))
posterPath = await imageStore.DownloadShowPosterAsync(
show.Id,
meta.PosterUrl,
cancellationToken
);
{
var downloaded = await downloader.DownloadAsync(meta.PosterUrl, cancellationToken);
if (downloaded is not null)
{
var image = Image.Create(ImageCategory.ShowPoster, downloaded.Extension, show.Name);
dbContext.Images.Add(image);
await imageStore.SaveAsync(
image.Id,
downloaded.Extension,
downloaded.Content,
cancellationToken
);
posterImageId = image.Id;
}
}
show.ApplyMetadata(command.Provider, meta.ExternalId, meta.Overview, meta.Year, posterPath);
show.ApplyMetadata(command.Provider, meta.ExternalId, meta.Overview, meta.Year, posterImageId);
return Result.Success();
}
}
@@ -6,10 +6,8 @@ using TeleWave.Application.Library;
namespace TeleWave.Application.Metadata.ClearShowMetadata;
public sealed class ClearShowMetadataCommandHandler(
IAppDbContext dbContext,
IMetadataImageStore imageStore
) : ICommandHandler<ClearShowMetadataCommand, Result>
public sealed class ClearShowMetadataCommandHandler(IAppDbContext dbContext)
: ICommandHandler<ClearShowMetadataCommand, Result>
{
public async Task<Result> Handle(
ClearShowMetadataCommand command,
@@ -23,7 +21,7 @@ public sealed class ClearShowMetadataCommandHandler(
if (show is null)
return Result.Failure(ShowErrors.NotFound);
imageStore.DeleteShowImages(show.Id);
// Отвязываем постер; сама картинка остаётся в галерее (удаляется отдельно из неё).
show.ClearMetadata();
return Result.Success();
}
@@ -3,5 +3,5 @@ using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Metadata.SetShowPoster;
/// <summary>Привязать к шоу загруженный вручную постер (файл уже сохранён хранилищем).</summary>
public sealed record SetShowPosterCommand(Guid ShowId, string PosterPath) : ICommand<Result>;
/// <summary>Привязать/снять постер шоу по ссылке на запись реестра изображений (null — отвязать).</summary>
public sealed record SetShowPosterCommand(Guid ShowId, Guid? ImageId) : ICommand<Result>;
@@ -21,7 +21,7 @@ public sealed class SetShowPosterCommandHandler(IAppDbContext dbContext)
if (show is null)
return Result.Failure(ShowErrors.NotFound);
show.SetPosterPath(command.PosterPath);
show.SetPosterImage(command.ImageId);
return Result.Success();
}
}
@@ -41,7 +41,7 @@ public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext)
var showIds = entries.Where(e => e.ShowId != null).Select(e => e.ShowId!.Value).Distinct().ToList();
var shows = await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new { s.Id, s.Name, s.PosterPath })
.Select(s => new { s.Id, s.Name, s.PosterImageId })
.ToDictionaryAsync(s => s.Id, cancellationToken);
// Метаданные серий: ключ — (шоу, ассет).
@@ -78,7 +78,7 @@ public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext)
e.EndsAtUtc,
e.ShowId,
show?.Name,
show?.PosterPath is not null,
show?.PosterImageId,
episode?.Id,
episode?.Title,
episode?.Overview,
@@ -42,7 +42,7 @@ public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
var showIds = currentShowByChannel.Values.Distinct().ToList();
var shows = await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new { s.Id, s.Name, s.PosterPath })
.Select(s => new { s.Id, s.Name, s.PosterImageId })
.ToDictionaryAsync(s => s.Id, cancellationToken);
return channels
@@ -56,7 +56,7 @@ public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
c.Name,
show is null ? null : showId,
show?.Name,
show?.PosterPath is not null
show?.PosterImageId
);
})
.ToList();
@@ -8,7 +8,7 @@ public sealed record PublicChannelDto(
string Name,
Guid? CurrentShowId,
string? CurrentShowName,
bool CurrentShowHasPoster
Guid? CurrentShowPosterImageId
);
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
@@ -18,7 +18,7 @@ public sealed record PublicEpgEntryDto(
DateTimeOffset EndsAtUtc,
Guid? ShowId,
string? ShowName,
bool ShowHasPoster,
Guid? ShowPosterImageId,
Guid? EpisodeId,
string? EpisodeTitle,
string? EpisodeOverview,