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
@@ -75,7 +75,7 @@ public static class ChannelEndpoints
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}/background", UploadTemplateBackground)
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}/background", SetTemplateBackground)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/background", ClearTemplateBackground)
@@ -333,27 +333,18 @@ public static class ChannelEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> UploadTemplateBackground(
private static async Task<IResult> SetTemplateBackground(
Guid id,
Guid templateId,
string fileName,
HttpRequest request,
IBumperTemplateStorage storage,
SetBumperTemplateBackgroundBody body,
ISender sender,
CancellationToken cancellationToken
)
{
if (ResolveBumperExtension(fileName, request, BumperFiles.BackgroundExtensions) is not { } ext)
return ChannelErrors.InvalidBumperFile.ToProblem();
await storage.SaveBackgroundAsync(templateId, ext, request.Body, cancellationToken);
var result = await sender.Send(
new SetBumperTemplateBackgroundCommand(id, templateId, ext),
new SetBumperTemplateBackgroundCommand(id, templateId, body.ImageId),
cancellationToken
);
if (!result.IsSuccess)
storage.DeleteBackground(templateId);
return result.ToHttpResult();
}
@@ -539,6 +530,8 @@ public sealed record AddChannelAdBody(Guid MediaAssetId);
public sealed record AddBumperTemplateBody(string Name);
public sealed record SetBumperTemplateBackgroundBody(Guid ImageId);
public sealed record UpdateBumperTemplateBody(
string Name,
string BackgroundColor,
@@ -547,24 +540,11 @@ public sealed record UpdateBumperTemplateBody(
string TextColor
);
/// <summary>Ограничения на загружаемые файлы блока заставки (звук/фон-картинка).</summary>
/// <summary>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</summary>
internal static class BumperFiles
{
public const long MaxBytes = 200L * 1024 * 1024; // 200 МБ
// Фон блока — только картинка (видео-фоны в новой модели не поддерживаются).
public static readonly IReadOnlySet<string> BackgroundExtensions = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
{
".jpg",
".jpeg",
".png",
".webp",
".bmp",
".gif",
};
public static readonly IReadOnlySet<string> AudioExtensions = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
@@ -1,5 +1,4 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Images.DeleteImage;
@@ -48,12 +47,7 @@ public static class MetadataEndpoints
.Produces(StatusCodes.Status204NoContent);
admin.MapPost("/shows/{showId:guid}/refresh-episodes", RefreshEpisodes).Produces<int>();
// Кадры серий отдаём публично (просто картинки, id не угадать) — чтобы работал <img src>.
// Постеры шоу теперь в общем реестре и отдаются по /api/images/{id}.
app.MapGet("/api/metadata/episodes/{episodeId:guid}/still", ServeStill)
.WithTags("Metadata")
.Produces(StatusCodes.Status200OK);
// Постеры шоу и кадры серий теперь в общем реестре и отдаются по /api/images/{id}.
return app;
}
@@ -181,37 +175,6 @@ public static class MetadataEndpoints
);
return result.ToHttpResult();
}
private static async Task<IResult> ServeStill(
Guid episodeId,
IAppDbContext dbContext,
IMetadataImageStore imageStore,
CancellationToken cancellationToken
)
{
var path = await dbContext.Shows.AsNoTracking()
.SelectMany(s => s.Episodes)
.Where(e => e.Id == episodeId)
.Select(e => e.StillPath)
.FirstOrDefaultAsync(cancellationToken);
return ServeImage(path, imageStore);
}
private static IResult ServeImage(string? relativePath, IMetadataImageStore imageStore)
{
if (string.IsNullOrEmpty(relativePath))
return Results.NotFound();
var abs = imageStore.ResolveAbsolutePath(relativePath);
return abs is null ? Results.NotFound() : Results.File(abs, ContentTypeFor(Path.GetExtension(abs)));
}
private static string ContentTypeFor(string extension) =>
extension.ToLowerInvariant() switch
{
".png" => "image/png",
".webp" => "image/webp",
_ => "image/jpeg",
};
}
public sealed record ApplyMetadataBody(string Provider, string ExternalId);
@@ -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);
@@ -25,8 +25,8 @@ public class BumperTemplate
public string AccentColor { get; private set; } = DefaultAccentColor;
public string TextColor { get; private set; } = DefaultTextColor;
/// <summary>Расширение загруженной фон-картинки (с точкой) или null тогда фон градиент/постер.</summary>
public string? BackgroundImageExtension { get; private set; }
/// <summary>Фон-картинка блока — ссылка на запись реестра изображений или null (тогда фон градиент/постер).</summary>
public Guid? BackgroundImageId { get; private set; }
/// <summary>Расширение загруженного звука (с точкой) или null — тогда синтезируется джингл.</summary>
public string? AudioExtension { get; private set; }
@@ -59,7 +59,7 @@ public class BumperTemplate
BackgroundColor2 = DefaultBackgroundColor2,
AccentColor = DefaultAccentColor,
TextColor = DefaultTextColor,
BackgroundImageExtension = null,
BackgroundImageId = null,
AudioExtension = null,
AudioDurationSeconds = null,
Revision = 0,
@@ -99,18 +99,18 @@ public class BumperTemplate
Revision++;
}
/// <summary>Отметить загруженную фон-картинку (extension — с точкой, нижний регистр). Меняет ревизию.</summary>
public void SetBackgroundImage(string extension)
/// <summary>Привязать фон-картинку блока (ссылка на реестр изображений). Меняет ревизию.</summary>
public void SetBackgroundImage(Guid imageId)
{
BackgroundImageExtension = extension;
BackgroundImageId = imageId;
Revision++;
}
public void ClearBackgroundImage()
{
if (BackgroundImageExtension is null)
if (BackgroundImageId is null)
return;
BackgroundImageExtension = null;
BackgroundImageId = null;
Revision++;
}
}
@@ -18,8 +18,8 @@ public class ShowEpisode
public string? Title { get; private set; }
public string? Overview { get; private set; }
/// <summary>Относительный путь локального кадра или null.</summary>
public string? StillPath { get; private set; }
/// <summary>Кадр серии — ссылка на запись общего реестра изображений или null.</summary>
public Guid? StillImageId { get; private set; }
public DateOnly? AirDate { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
@@ -43,13 +43,13 @@ public class ShowEpisode
Episode = episode;
}
/// <summary>Применить метаданные серии (кадр — уже скачанный локально — может быть null).</summary>
public void ApplyMetadata(string? title, string? overview, string? stillPath, DateOnly? airDate)
/// <summary>Применить метаданные серии (кадр — уже зарегистрирован в реестре — может быть null).</summary>
public void ApplyMetadata(string? title, string? overview, Guid? stillImageId, DateOnly? airDate)
{
Title = title;
Overview = overview;
if (stillPath is not null)
StillPath = stillPath;
if (stillImageId is not null)
StillImageId = stillImageId;
AirDate = airDate;
}
@@ -57,7 +57,7 @@ public class ShowEpisode
{
Title = null;
Overview = null;
StillPath = null;
StillImageId = null;
AirDate = null;
}
}
@@ -110,7 +110,6 @@ public static class DependencyInjection
services.AddSingleton<IMetadataProvider, TmdbMetadataProvider>();
services.AddSingleton<IMetadataProvider, OmdbMetadataProvider>();
services.AddSingleton<IMetadataProviderResolver, MetadataProviderResolver>();
services.AddSingleton<IMetadataImageStore, MetadataImageStore>();
}
/// <summary>Планировщик расписания: генератор, источник случайности и фоновый сервис горизонта.</summary>
@@ -3,13 +3,12 @@ using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Media;
/// <summary>
/// Файловое хранилище блоков заставок: сырые звук/фон под bumpers/{templateId}/{kind}{ext}.
/// На блок — не более одного файла каждого вида (при загрузке старый удаляется).
/// Файловое хранилище звука блоков заставок: bumpers/{templateId}/audio{ext}. На блок — не более
/// одного файла (при загрузке старый удаляется). Фон-картинка блока хранится в общем реестре.
/// </summary>
public sealed class BumperTemplateStorage(MediaPathResolver paths) : IBumperTemplateStorage
{
private const string Audio = "audio";
private const string Background = "background";
public Task SaveAudioAsync(
Guid templateId,
@@ -18,17 +17,8 @@ public sealed class BumperTemplateStorage(MediaPathResolver paths) : IBumperTemp
CancellationToken cancellationToken
) => SaveAsync(templateId, Audio, extension, content, cancellationToken);
public Task SaveBackgroundAsync(
Guid templateId,
string extension,
Stream content,
CancellationToken cancellationToken
) => SaveAsync(templateId, Background, extension, content, cancellationToken);
public void DeleteAudio(Guid templateId) => DeleteKind(templateId, Audio);
public void DeleteBackground(Guid templateId) => DeleteKind(templateId, Background);
public void DeleteTemplate(Guid templateId)
{
var dir = paths.BumperTemplateDir(templateId);
@@ -39,9 +29,6 @@ public sealed class BumperTemplateStorage(MediaPathResolver paths) : IBumperTemp
public string? AudioPath(Guid templateId, string? extension) =>
ResolvePath(templateId, Audio, extension);
public string? BackgroundPath(Guid templateId, string? extension) =>
ResolvePath(templateId, Background, extension);
private async Task SaveAsync(
Guid templateId,
string kind,
@@ -1,67 +0,0 @@
using TeleWave.Application.Common.Interfaces;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Infrastructure.Metadata;
/// <summary>Скачивает и хранит картинки метаданных локально под metadata/shows/{id}.</summary>
public sealed class MetadataImageStore(MediaPathResolver paths, IHttpClientFactory httpFactory)
: IMetadataImageStore
{
public async Task<string?> DownloadEpisodeStillAsync(
Guid episodeId,
string url,
CancellationToken cancellationToken
)
{
try
{
var client = httpFactory.CreateClient("metadata");
using var response = await client.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
var ext = ExtensionFor(url, response.Content.Headers.ContentType?.MediaType);
var dir = paths.MetadataEpisodeDir(episodeId);
Directory.CreateDirectory(dir);
RemoveExisting(dir, "still");
var abs = paths.MetadataEpisodeStillPath(episodeId, ext);
await using var content = await response.Content.ReadAsStreamAsync(cancellationToken);
await using (var fs = File.Create(abs))
await content.CopyToAsync(fs, cancellationToken);
return paths.MetadataEpisodeStillRelative(episodeId, ext);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException)
{
return null;
}
}
public void DeleteEpisodeImages(Guid episodeId)
{
var dir = paths.MetadataEpisodeDir(episodeId);
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
public string? ResolveAbsolutePath(string relativePath)
{
var abs = paths.ResolveRelative(relativePath);
return abs is not null && File.Exists(abs) ? abs : null;
}
private static void RemoveExisting(string dir, string baseName)
{
foreach (var file in Directory.EnumerateFiles(dir, baseName + ".*"))
File.Delete(file);
}
private static string ExtensionFor(string url, string? mediaType) =>
mediaType switch
{
"image/png" => ".png",
"image/webp" => ".webp",
"image/jpeg" => ".jpg",
_ => Path.GetExtension(new Uri(url).AbsolutePath) is { Length: > 1 } e ? e.ToLowerInvariant() : ".jpg",
};
}
@@ -0,0 +1,887 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TeleWave.Infrastructure.Persistence;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260725085149_EpisodeStillAndBumperBgImages")]
partial class EpisodeStillAndBumperBgImages
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReplacedByTokenHash")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FromShowId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<string>("Signature")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("ToShowId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("FromShowId", "ToShowId", "Signature");
b.ToTable("BumperAssets");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AccentColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<double?>("AudioDurationSeconds")
.HasColumnType("double precision");
b.Property<string>("AudioExtension")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("BackgroundColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("BackgroundColor2")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid?>("BackgroundImageId")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("Revision")
.HasColumnType("integer");
b.Property<string>("TextColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.HasKey("Id");
b.HasIndex("ChannelId", "Position");
b.ToTable("BumperTemplate");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("AdInsertion")
.HasColumnType("integer");
b.Property<int>("AdsPerBreak")
.HasColumnType("integer");
b.Property<int>("BumperFont")
.HasColumnType("integer");
b.Property<int>("BumperMinIntervalMinutes")
.HasColumnType("integer");
b.Property<string>("BumperNextLabel")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BumperNowLabel")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("BumperOnlyBetweenDifferentShows")
.HasColumnType("boolean");
b.Property<int>("BumperSelection")
.HasColumnType("integer");
b.Property<bool>("BumpersEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("EpochUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("FillerAssetId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("NextAdIndex")
.HasColumnType("integer");
b.Property<int>("NextBumperIndex")
.HasColumnType("integer");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "Position");
b.ToTable("ChannelAd");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("BlockMode")
.HasColumnType("integer");
b.Property<int>("BlockValue")
.HasColumnType("integer");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<int>("NextEpisodeIndex")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "ShowId");
b.ToTable("ChannelShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ProgrammingOverrideId")
.HasColumnType("uuid");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ProgrammingOverrideId");
b.ToTable("OverrideShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Mode")
.HasColumnType("integer");
b.Property<DateTimeOffset>("StartsAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc");
b.ToTable("ProgrammingOverride");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int?>("EpisodeIndex")
.HasColumnType("integer");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<Guid?>("ShowId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("StartsAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ChannelId", "EndsAtUtc");
b.HasIndex("ChannelId", "ShowId");
b.HasIndex("ChannelId", "StartsAtUtc");
b.ToTable("ScheduleEntries");
});
modelBuilder.Entity("TeleWave.Domain.Images.Image", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("Category")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileExtension")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("OriginalFileName")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Id");
b.HasIndex("Category", "CreatedAt");
b.ToTable("Images");
});
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<string>("MetadataExternalId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("MetadataProvider")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("OriginalName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("PosterImageId")
.HasColumnType("uuid");
b.Property<int?>("Year")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateOnly?>("AirDate")
.HasColumnType("date");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("Episode")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<string>("Overview")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int?>("Season")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<Guid?>("StillImageId")
.HasColumnType("uuid");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Id");
b.HasIndex("MediaAssetId");
b.HasIndex("ShowId", "Position");
b.ToTable("ShowEpisode");
});
modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AudioCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<TimeSpan?>("Duration")
.HasColumnType("interval");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int?>("Height")
.HasColumnType("integer");
b.Property<string>("OriginalExtension")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RelativePath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("SegmentCount")
.HasColumnType("integer");
b.Property<int?>("SegmentSeconds")
.HasColumnType("integer");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VideoCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int?>("Width")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("MediaAssets");
});
modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Key");
b.ToTable("AppSettings");
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsBlocked")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("BumperTemplates")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Ads")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Shows")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null)
.WithMany("Shows")
.HasForeignKey("ProgrammingOverrideId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Overrides")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
{
b.HasOne("TeleWave.Domain.Library.Show", null)
.WithMany("Episodes")
.HasForeignKey("ShowId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Navigation("Ads");
b.Navigation("BumperTemplates");
b.Navigation("Overrides");
b.Navigation("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.Navigation("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
{
b.Navigation("Episodes");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,93 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class EpisodeStillAndBumperBgImages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "StillImageId",
table: "ShowEpisode",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "BackgroundImageId",
table: "BumperTemplate",
type: "uuid",
nullable: true);
// Кадры серий → реестр (Category=2 EpisodeStill); файлы перекладывает RelocateLegacyImagesAsync.
migrationBuilder.Sql(
"""
DO $$
DECLARE r RECORD; img uuid;
BEGIN
FOR r IN SELECT "Id", "StillPath" FROM "ShowEpisode" WHERE "StillPath" IS NOT NULL LOOP
img := gen_random_uuid();
INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt")
VALUES (img, 2, lower(coalesce(substring(r."StillPath" from '\.[^.]*$'), '.jpg')), 'still', now());
UPDATE "ShowEpisode" SET "StillImageId" = img WHERE "Id" = r."Id";
END LOOP;
END $$;
"""
);
// Фоны блоков заставок → реестр (Category=3 BumperBackground).
migrationBuilder.Sql(
"""
DO $$
DECLARE r RECORD; img uuid;
BEGIN
FOR r IN SELECT "Id", "BackgroundImageExtension" FROM "BumperTemplate" WHERE "BackgroundImageExtension" IS NOT NULL LOOP
img := gen_random_uuid();
INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt")
VALUES (img, 3, lower(r."BackgroundImageExtension"), 'background', now());
UPDATE "BumperTemplate" SET "BackgroundImageId" = img WHERE "Id" = r."Id";
END LOOP;
END $$;
"""
);
migrationBuilder.DropColumn(
name: "StillPath",
table: "ShowEpisode");
migrationBuilder.DropColumn(
name: "BackgroundImageExtension",
table: "BumperTemplate");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "StillImageId",
table: "ShowEpisode");
migrationBuilder.DropColumn(
name: "BackgroundImageId",
table: "BumperTemplate");
migrationBuilder.AddColumn<string>(
name: "StillPath",
table: "ShowEpisode",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "BackgroundImageExtension",
table: "BumperTemplate",
type: "character varying(16)",
maxLength: 16,
nullable: true);
}
}
}
@@ -215,9 +215,8 @@ namespace TeleWave.Infrastructure.Migrations
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("BackgroundImageExtension")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid?>("BackgroundImageId")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
@@ -554,9 +553,8 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<string>("StillPath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("StillImageId")
.HasColumnType("uuid");
b.Property<string>("Title")
.HasMaxLength(512)
@@ -69,7 +69,6 @@ public class BumperTemplateConfiguration : IEntityTypeConfiguration<BumperTempla
builder.Property(x => x.BackgroundColor2).IsRequired().HasMaxLength(32);
builder.Property(x => x.AccentColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.TextColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.BackgroundImageExtension).HasMaxLength(16);
builder.Property(x => x.AudioExtension).HasMaxLength(16);
}
}
@@ -32,6 +32,5 @@ public class ShowEpisodeConfiguration : IEntityTypeConfiguration<ShowEpisode>
builder.HasIndex(x => x.MediaAssetId);
builder.Property(x => x.Title).HasMaxLength(512);
builder.Property(x => x.Overview).HasMaxLength(4096);
builder.Property(x => x.StillPath).HasMaxLength(256);
}
}
@@ -31,31 +31,56 @@ public static class MigrationExtensions
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var paths = scope.ServiceProvider.GetRequiredService<MediaPathResolver>();
Directory.CreateDirectory(paths.ImagesDir);
// Постеры шоу: metadata/shows/{showId}/poster{ext} → images/{imageId}{ext}.
var posters = await dbContext.Shows.AsNoTracking()
.Where(s => s.PosterImageId != null)
.Join(
dbContext.Images,
s => s.PosterImageId,
i => i.Id,
(s, i) => new
{
ShowId = s.Id,
ImageId = i.Id,
i.FileExtension,
}
(s, i) => new { EntityId = s.Id, ImageId = i.Id, i.FileExtension }
)
.ToListAsync(cancellationToken);
foreach (var p in posters)
{
var target = paths.ImagePath(p.ImageId, p.FileExtension);
if (File.Exists(target))
continue;
var legacy = paths.MetadataShowPosterPath(p.ShowId, p.FileExtension);
if (!File.Exists(legacy))
continue;
Relocate(paths.ImagePath(p.ImageId, p.FileExtension), paths.MetadataShowPosterPath(p.EntityId, p.FileExtension));
Directory.CreateDirectory(paths.ImagesDir);
// Кадры серий: metadata/episodes/{episodeId}/still{ext} → images/{imageId}{ext}.
var stills = await dbContext.Shows.AsNoTracking()
.SelectMany(s => s.Episodes)
.Where(e => e.StillImageId != null)
.Join(
dbContext.Images,
e => e.StillImageId,
i => i.Id,
(e, i) => new { EntityId = e.Id, ImageId = i.Id, i.FileExtension }
)
.ToListAsync(cancellationToken);
foreach (var s in stills)
Relocate(paths.ImagePath(s.ImageId, s.FileExtension), paths.MetadataEpisodeStillPath(s.EntityId, s.FileExtension));
// Фоны блоков заставок: bumpers/{templateId}/background{ext} → images/{imageId}{ext}.
var backgrounds = await dbContext.Channels.AsNoTracking()
.SelectMany(c => c.BumperTemplates)
.Where(t => t.BackgroundImageId != null)
.Join(
dbContext.Images,
t => t.BackgroundImageId,
i => i.Id,
(t, i) => new { EntityId = t.Id, ImageId = i.Id, i.FileExtension }
)
.ToListAsync(cancellationToken);
foreach (var b in backgrounds)
Relocate(
paths.ImagePath(b.ImageId, b.FileExtension),
paths.BumperTemplateFilePath(b.EntityId, "background", b.FileExtension)
);
static void Relocate(string target, string legacy)
{
if (File.Exists(target) || !File.Exists(legacy))
return;
File.Move(legacy, target);
}
}
@@ -4,6 +4,8 @@ import Hls from 'hls.js'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronDown, ChevronLeft, RefreshCw } from 'lucide-react'
import { imageUrl } from '@/features/admin/images/api'
import { ImageGallery } from '@/features/admin/images/ImageGallery'
import { getAccessToken, HttpError } from '@/shared/api/client'
import type {
AdInsertion,
@@ -41,11 +43,11 @@ import {
removeChannelAd,
removeChannelShow,
renderBumperPreview,
setBumperTemplateBackground,
updateBumperTemplate,
updateChannelSettings,
updateChannelShow,
uploadBumperTemplateAudio,
uploadBumperTemplateBackground,
} from './api'
function formatTime(iso: string) {
@@ -654,17 +656,11 @@ function BumperTemplateEditor({
onSaved={onChanged}
onError={onError}
/>
<BumperFileUpload
<BumperBackgroundField
channelId={channelId}
templateId={template.id}
kind="background"
label={t('admin.channels.bumperBackground')}
hint={t('admin.channels.bumperBackgroundHint')}
has={template.hasBackground}
accept="image/*"
upload={uploadBumperTemplateBackground}
clear={clearBumperTemplateBackground}
onSaved={onChanged}
backgroundImageId={template.backgroundImageId}
onChanged={onChanged}
onError={onError}
/>
</div>
@@ -758,6 +754,71 @@ function BumperPreviewPlayer({
)
}
function BumperBackgroundField({
channelId,
templateId,
backgroundImageId,
onChanged,
onError,
}: {
channelId: string
templateId: string
backgroundImageId: string | null
onChanged: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const [galleryOpen, setGalleryOpen] = useState(false)
const setBg = useMutation({
mutationFn: (imageId: string) => setBumperTemplateBackground(channelId, templateId, imageId),
onSuccess: onChanged,
onError,
})
const clearBg = useMutation({
mutationFn: () => clearBumperTemplateBackground(channelId, templateId),
onSuccess: onChanged,
onError,
})
return (
<div className="flex flex-col gap-1.5">
<Label>
{t('admin.channels.bumperBackground')}{' '}
<span className={backgroundImageId ? 'text-emerald-500' : 'text-muted-foreground'}>
{backgroundImageId
? `· ${t('admin.channels.bumperFileLoaded')}`
: `· ${t('admin.channels.bumperFileDefault')}`}
</span>
</Label>
<span className="text-xs text-muted-foreground">{t('admin.channels.bumperBackgroundHint')}</span>
<div className="flex items-center gap-2">
{backgroundImageId && (
<img
src={imageUrl(backgroundImageId)}
alt=""
className="h-10 w-16 shrink-0 rounded border border-border object-cover"
/>
)}
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
{t('admin.channels.bumperBackgroundPick')}
</Button>
{backgroundImageId && (
<Button size="sm" variant="ghost" disabled={clearBg.isPending} onClick={() => clearBg.mutate()}>
{t('admin.channels.bumperReset')}
</Button>
)}
</div>
<ImageGallery
open={galleryOpen}
onOpenChange={setGalleryOpen}
category="BumperBackground"
onSelect={(img) => setBg.mutate(img.id)}
/>
</div>
)
}
function BumperFileUpload({
channelId,
templateId,
+6 -2
View File
@@ -137,8 +137,12 @@ export function uploadBumperTemplateAudio(id: string, templateId: string, file:
return uploadBumperTemplateFile(id, templateId, 'audio', file)
}
export function uploadBumperTemplateBackground(id: string, templateId: string, file: File) {
return uploadBumperTemplateFile(id, templateId, 'background', file)
/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */
export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
method: 'PUT',
body: { imageId },
})
}
export function clearBumperTemplateAudio(id: string, templateId: string) {
@@ -18,7 +18,8 @@ import {
} from '@/features/admin/media/episode-parse'
import { formatDuration } from '@/features/admin/media/MediaPanel'
import { ShowMetadataCard } from './ShowMetadataCard'
import { addEpisode, episodeStillUrl, getShow, removeEpisode } from './api'
import { imageUrl } from '@/features/admin/images/api'
import { addEpisode, getShow, removeEpisode } from './api'
type Candidate = { asset: MediaAssetDto; parsed: ParsedEpisode }
@@ -219,9 +220,9 @@ export function ShowDetail({ showId }: { showId: string }) {
<td className="px-4 py-2 text-muted-foreground">{index + 1}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
{episode.hasStill && (
{episode.stillImageId && (
<img
src={episodeStillUrl(episode.id)}
src={imageUrl(episode.stillImageId)}
alt=""
className="h-9 w-16 shrink-0 rounded object-cover"
/>
-5
View File
@@ -80,11 +80,6 @@ export function setShowPoster(showId: string, imageId: string | null) {
})
}
/** Ссылка на локальный кадр серии. */
export function episodeStillUrl(episodeId: string, bust?: string) {
return `/api/metadata/episodes/${episodeId}/still${bust ? `?v=${encodeURIComponent(bust)}` : ''}`
}
/** Довыгрузить метаданные серий из привязанного источника. Возвращает число обновлённых. */
export function refreshEpisodesMetadata(showId: string) {
return apiRequest<number>(`/admin/metadata/shows/${showId}/refresh-episodes`, { method: 'POST' })
+3 -3
View File
@@ -7,7 +7,7 @@ import { cn } from '@/shared/lib/cn'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { ChannelPlayer } from './ChannelPlayer'
import { episodeStillUrl, getEpg, imageUrl, listChannels, watchChannel } from './api'
import { getEpg, imageUrl, listChannels, watchChannel } from './api'
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
@@ -138,9 +138,9 @@ export function AirPage() {
<div className="flex flex-col gap-3">
{current && (
<div className="crt-panel flex gap-3 rounded-md p-3">
{currentEntry?.episodeHasStill && currentEntry.episodeId ? (
{currentEntry?.episodeStillImageId ? (
<img
src={episodeStillUrl(currentEntry.episodeId)}
src={imageUrl(currentEntry.episodeStillImageId)}
alt=""
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
/>
+1 -5
View File
@@ -1,14 +1,10 @@
import { apiRequest } from '@/shared/api/client'
import type { PublicChannelDto, PublicEpgEntryDto } from '@/shared/api/types'
/** Ссылка на изображение общего реестра (постеры) — по id из публичных DTO. */
/** Ссылка на изображение общего реестра (постеры/кадры) — по id из публичных DTO. */
export function imageUrl(imageId: string) {
return `/api/images/${imageId}`
}
/** Кадр серии (пока по-старому — публичный эндпоинт метаданных). */
export function episodeStillUrl(episodeId: string) {
return `/api/metadata/episodes/${episodeId}/still`
}
export function listChannels() {
return apiRequest<PublicChannelDto[]>('/channels')
+3 -3
View File
@@ -95,7 +95,7 @@ export type EpisodeDto = {
episode: number | null
title: string | null
overview: string | null
hasStill: boolean
stillImageId: string | null
airDate: string | null
}
@@ -148,7 +148,7 @@ export type BumperTemplateDto = {
backgroundColor2: string
accentColor: string
textColor: string
hasBackground: boolean
backgroundImageId: string | null
hasAudio: boolean
audioDurationSeconds: number | null
}
@@ -236,5 +236,5 @@ export type PublicEpgEntryDto = {
episodeId: string | null
episodeTitle: string | null
episodeOverview: string | null
episodeHasStill: boolean
episodeStillImageId: string | null
}
+2
View File
@@ -230,6 +230,7 @@ const resources = {
bumperPreviewHint: 'Пример со звуком и анимацией (примерные названия шоу). Использует сохранённые настройки.',
bumperBackground: 'Фон-картинка',
bumperBackgroundHint: 'Картинка фона; иначе — постер шоу или градиент',
bumperBackgroundPick: 'Выбрать из галереи',
bumperFileLoaded: 'загружено',
bumperFileDefault: 'по умолчанию',
bumperUpload: 'Загрузить',
@@ -533,6 +534,7 @@ const resources = {
bumperPreviewHint: 'Sample with sound and animation (example show names). Uses saved settings.',
bumperBackground: 'Background image',
bumperBackgroundHint: 'Background image; otherwise the show poster or a gradient',
bumperBackgroundPick: 'Pick from gallery',
bumperFileLoaded: 'loaded',
bumperFileDefault: 'default',
bumperUpload: 'Upload',