diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs index ec787d6..6e4920e 100644 --- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs @@ -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 UploadTemplateBackground( + private static async Task 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 ); -/// Ограничения на загружаемые файлы блока заставки (звук/фон-картинка). +/// Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр). internal static class BumperFiles { public const long MaxBytes = 200L * 1024 * 1024; // 200 МБ - // Фон блока — только картинка (видео-фоны в новой модели не поддерживаются). - public static readonly IReadOnlySet BackgroundExtensions = new HashSet( - StringComparer.OrdinalIgnoreCase - ) - { - ".jpg", - ".jpeg", - ".png", - ".webp", - ".bmp", - ".gif", - }; - public static readonly IReadOnlySet AudioExtensions = new HashSet( StringComparer.OrdinalIgnoreCase ) diff --git a/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs index 0e5b2ee..26c2282 100644 --- a/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs @@ -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(); - // Кадры серий отдаём публично (просто картинки, id не угадать) — чтобы работал . - // Постеры шоу теперь в общем реестре и отдаются по /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 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); diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommandHandler.cs index a8df2ec..2326c64 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommandHandler.cs @@ -5,10 +5,8 @@ using TeleWave.Application.Common.Models; namespace TeleWave.Application.Broadcast.Bumpers; -public sealed class ClearBumperTemplateBackgroundCommandHandler( - IAppDbContext dbContext, - IBumperTemplateStorage storage -) : ICommandHandler +public sealed class ClearBumperTemplateBackgroundCommandHandler(IAppDbContext dbContext) + : ICommandHandler { public async Task 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(); } } diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQueryHandler.cs index ba2204c..2a84250 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQueryHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQueryHandler.cs @@ -12,6 +12,7 @@ public sealed class RenderBumperPreviewQueryHandler( IAppDbContext dbContext, IBumperRenderer renderer, IBumperTemplateStorage storage, + IImageStore imageStore, IOptions bumperOptions, IOptions streamingOptions ) : IQueryHandler> @@ -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 diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommand.cs index fde0552..0acbb96 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommand.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommand.cs @@ -3,9 +3,9 @@ using TeleWave.Application.Common.Models; namespace TeleWave.Application.Broadcast.Bumpers; -/// Отметить загруженную фон-картинку блока (расширение — с точкой). +/// Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). public sealed record SetBumperTemplateBackgroundCommand( Guid ChannelId, Guid TemplateId, - string Extension + Guid ImageId ) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommandHandler.cs index d699cd0..254b070 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommandHandler.cs @@ -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(); } } diff --git a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs index 5fd7e6c..7625a82 100644 --- a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs +++ b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs @@ -47,7 +47,7 @@ public sealed record BumperTemplateDto( string BackgroundColor2, string AccentColor, string TextColor, - bool HasBackground, + Guid? BackgroundImageId, bool HasAudio, double? AudioDurationSeconds ); diff --git a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs index 6213bf5..fbd5390 100644 --- a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs @@ -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 )) diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs index c5abbd9..c74d76b 100644 --- a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs @@ -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(); + 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, diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IBumperTemplateStorage.cs b/backend/src/TeleWave.Application/Common/Interfaces/IBumperTemplateStorage.cs index 0d0167a..ed86c87 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IBumperTemplateStorage.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IBumperTemplateStorage.cs @@ -1,8 +1,8 @@ namespace TeleWave.Application.Common.Interfaces; /// -/// Хранилище сырых файлов блоков заставок (звук и фон-картинка) под bumpers/{templateId}. В отличие -/// от обычных ассетов эти файлы НЕ режутся на HLS — они подаются как входы в рендер заставки. +/// Хранилище звука блоков заставок под bumpers/{templateId}. Фон-картинка блока живёт в общем реестре +/// изображений (см. IImageStore). Звук НЕ режется на HLS — подаётся входом в рендер заставки. /// 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); /// Удалить все файлы блока (при удалении самого блока). void DeleteTemplate(Guid templateId); /// Абсолютный путь к загруженному звуку или null (нет расширения / файл отсутствует). string? AudioPath(Guid templateId, string? extension); - - /// Абсолютный путь к загруженной фон-картинке или null. - string? BackgroundPath(Guid templateId, string? extension); } diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IMetadataImageStore.cs b/backend/src/TeleWave.Application/Common/Interfaces/IMetadataImageStore.cs deleted file mode 100644 index 7286408..0000000 --- a/backend/src/TeleWave.Application/Common/Interfaces/IMetadataImageStore.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace TeleWave.Application.Common.Interfaces; - -/// -/// Локальное хранилище картинок метаданных (постеры/кадры) под metadata/ в корне хранилища. Скачивает -/// изображения к себе, чтобы не зависеть от внешнего CDN на этапе показа. -/// -public interface IMetadataImageStore -{ - /// Скачивает кадр серии по URL. Возвращает относительный путь или null при ошибке. - Task DownloadEpisodeStillAsync( - Guid episodeId, - string url, - CancellationToken cancellationToken - ); - - void DeleteEpisodeImages(Guid episodeId); - - /// Абсолютный путь к файлу по относительному (с защитой от traversal) или null, если вне корня. - string? ResolveAbsolutePath(string relativePath); -} diff --git a/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs b/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs index 74fac74..ca74dd0 100644 --- a/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs +++ b/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs @@ -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 ); }) diff --git a/backend/src/TeleWave.Application/Library/ShowDtos.cs b/backend/src/TeleWave.Application/Library/ShowDtos.cs index b9df1d9..894eb03 100644 --- a/backend/src/TeleWave.Application/Library/ShowDtos.cs +++ b/backend/src/TeleWave.Application/Library/ShowDtos.cs @@ -25,7 +25,7 @@ public sealed record EpisodeDto( int? Episode, string? Title, string? Overview, - bool HasStill, + Guid? StillImageId, DateOnly? AirDate ); diff --git a/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommandHandler.cs b/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommandHandler.cs index a8f2e97..eda3a04 100644 --- a/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommandHandler.cs +++ b/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommandHandler.cs @@ -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> { public async Task> 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++; } diff --git a/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQueryHandler.cs b/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQueryHandler.cs index 0955fe2..b23cace 100644 --- a/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQueryHandler.cs +++ b/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQueryHandler.cs @@ -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(); diff --git a/backend/src/TeleWave.Application/Streaming/StreamingDtos.cs b/backend/src/TeleWave.Application/Streaming/StreamingDtos.cs index 84d1482..feae21e 100644 --- a/backend/src/TeleWave.Application/Streaming/StreamingDtos.cs +++ b/backend/src/TeleWave.Application/Streaming/StreamingDtos.cs @@ -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); diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperTemplate.cs b/backend/src/TeleWave.Domain/Broadcast/BumperTemplate.cs index 5f1cd27..034fd78 100644 --- a/backend/src/TeleWave.Domain/Broadcast/BumperTemplate.cs +++ b/backend/src/TeleWave.Domain/Broadcast/BumperTemplate.cs @@ -25,8 +25,8 @@ public class BumperTemplate public string AccentColor { get; private set; } = DefaultAccentColor; public string TextColor { get; private set; } = DefaultTextColor; - /// Расширение загруженной фон-картинки (с точкой) или null — тогда фон градиент/постер. - public string? BackgroundImageExtension { get; private set; } + /// Фон-картинка блока — ссылка на запись реестра изображений или null (тогда фон градиент/постер). + public Guid? BackgroundImageId { get; private set; } /// Расширение загруженного звука (с точкой) или null — тогда синтезируется джингл. 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++; } - /// Отметить загруженную фон-картинку (extension — с точкой, нижний регистр). Меняет ревизию. - public void SetBackgroundImage(string extension) + /// Привязать фон-картинку блока (ссылка на реестр изображений). Меняет ревизию. + 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++; } } diff --git a/backend/src/TeleWave.Domain/Library/ShowEpisode.cs b/backend/src/TeleWave.Domain/Library/ShowEpisode.cs index 27a233e..4350035 100644 --- a/backend/src/TeleWave.Domain/Library/ShowEpisode.cs +++ b/backend/src/TeleWave.Domain/Library/ShowEpisode.cs @@ -18,8 +18,8 @@ public class ShowEpisode public string? Title { get; private set; } public string? Overview { get; private set; } - /// Относительный путь локального кадра или null. - public string? StillPath { get; private set; } + /// Кадр серии — ссылка на запись общего реестра изображений или null. + 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; } - /// Применить метаданные серии (кадр — уже скачанный локально — может быть null). - public void ApplyMetadata(string? title, string? overview, string? stillPath, DateOnly? airDate) + /// Применить метаданные серии (кадр — уже зарегистрирован в реестре — может быть null). + 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; } } diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs index 573845b..b4cf8f3 100644 --- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -110,7 +110,6 @@ public static class DependencyInjection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); } /// Планировщик расписания: генератор, источник случайности и фоновый сервис горизонта. diff --git a/backend/src/TeleWave.Infrastructure/Media/BumperTemplateStorage.cs b/backend/src/TeleWave.Infrastructure/Media/BumperTemplateStorage.cs index 78048e5..5852aaf 100644 --- a/backend/src/TeleWave.Infrastructure/Media/BumperTemplateStorage.cs +++ b/backend/src/TeleWave.Infrastructure/Media/BumperTemplateStorage.cs @@ -3,13 +3,12 @@ using TeleWave.Application.Common.Interfaces; namespace TeleWave.Infrastructure.Media; /// -/// Файловое хранилище блоков заставок: сырые звук/фон под bumpers/{templateId}/{kind}{ext}. -/// На блок — не более одного файла каждого вида (при загрузке старый удаляется). +/// Файловое хранилище звука блоков заставок: bumpers/{templateId}/audio{ext}. На блок — не более +/// одного файла (при загрузке старый удаляется). Фон-картинка блока хранится в общем реестре. /// 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, diff --git a/backend/src/TeleWave.Infrastructure/Metadata/MetadataImageStore.cs b/backend/src/TeleWave.Infrastructure/Metadata/MetadataImageStore.cs deleted file mode 100644 index 40ad7cd..0000000 --- a/backend/src/TeleWave.Infrastructure/Metadata/MetadataImageStore.cs +++ /dev/null @@ -1,67 +0,0 @@ -using TeleWave.Application.Common.Interfaces; -using TeleWave.Infrastructure.Media; - -namespace TeleWave.Infrastructure.Metadata; - -/// Скачивает и хранит картинки метаданных локально под metadata/shows/{id}. -public sealed class MetadataImageStore(MediaPathResolver paths, IHttpClientFactory httpFactory) - : IMetadataImageStore -{ - public async Task 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", - }; -} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725085149_EpisodeStillAndBumperBgImages.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725085149_EpisodeStillAndBumperBgImages.Designer.cs new file mode 100644 index 0000000..483b5bc --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725085149_EpisodeStillAndBumperBgImages.Designer.cs @@ -0,0 +1,887 @@ +// +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 + { + /// + 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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperNextLabel") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperNowLabel") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperOnlyBetweenDifferentShows") + .HasColumnType("boolean"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextBumperIndex") + .HasColumnType("integer"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("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", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", 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", 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 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725085149_EpisodeStillAndBumperBgImages.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725085149_EpisodeStillAndBumperBgImages.cs new file mode 100644 index 0000000..eca4273 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725085149_EpisodeStillAndBumperBgImages.cs @@ -0,0 +1,93 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class EpisodeStillAndBumperBgImages : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "StillImageId", + table: "ShowEpisode", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + 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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "StillImageId", + table: "ShowEpisode"); + + migrationBuilder.DropColumn( + name: "BackgroundImageId", + table: "BumperTemplate"); + + migrationBuilder.AddColumn( + name: "StillPath", + table: "ShowEpisode", + type: "character varying(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "BackgroundImageExtension", + table: "BumperTemplate", + type: "character varying(16)", + maxLength: 16, + nullable: true); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 951bab5..6d58334 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -215,9 +215,8 @@ namespace TeleWave.Infrastructure.Migrations .HasMaxLength(32) .HasColumnType("character varying(32)"); - b.Property("BackgroundImageExtension") - .HasMaxLength(16) - .HasColumnType("character varying(16)"); + b.Property("BackgroundImageId") + .HasColumnType("uuid"); b.Property("ChannelId") .HasColumnType("uuid"); @@ -554,9 +553,8 @@ namespace TeleWave.Infrastructure.Migrations b.Property("ShowId") .HasColumnType("uuid"); - b.Property("StillPath") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); + b.Property("StillImageId") + .HasColumnType("uuid"); b.Property("Title") .HasMaxLength(512) diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs index 8d0246b..3888b4a 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs @@ -69,7 +69,6 @@ public class BumperTemplateConfiguration : IEntityTypeConfiguration 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); } } diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs index 2f20b24..2fc2901 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs @@ -32,6 +32,5 @@ public class ShowEpisodeConfiguration : IEntityTypeConfiguration 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); } } diff --git a/backend/src/TeleWave.Infrastructure/Persistence/MigrationExtensions.cs b/backend/src/TeleWave.Infrastructure/Persistence/MigrationExtensions.cs index 8cfd2fa..2ed3b7b 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/MigrationExtensions.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/MigrationExtensions.cs @@ -31,31 +31,56 @@ public static class MigrationExtensions var dbContext = scope.ServiceProvider.GetRequiredService(); var paths = scope.ServiceProvider.GetRequiredService(); + 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); } } diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index 9c5880d..4391467 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -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} /> - @@ -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 ( +
+ + {t('admin.channels.bumperBackgroundHint')} +
+ {backgroundImageId && ( + + )} + + {backgroundImageId && ( + + )} +
+ setBg.mutate(img.id)} + /> +
+ ) +} + function BumperFileUpload({ channelId, templateId, diff --git a/frontend/src/features/admin/channels/api.ts b/frontend/src/features/admin/channels/api.ts index 46937a5..49f432d 100644 --- a/frontend/src/features/admin/channels/api.ts +++ b/frontend/src/features/admin/channels/api.ts @@ -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(`/admin/channels/${id}/bumper/templates/${templateId}/background`, { + method: 'PUT', + body: { imageId }, + }) } export function clearBumperTemplateAudio(id: string, templateId: string) { diff --git a/frontend/src/features/admin/shows/ShowDetail.tsx b/frontend/src/features/admin/shows/ShowDetail.tsx index f7e89a1..0a8bd7a 100644 --- a/frontend/src/features/admin/shows/ShowDetail.tsx +++ b/frontend/src/features/admin/shows/ShowDetail.tsx @@ -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 }) { {index + 1}
- {episode.hasStill && ( + {episode.stillImageId && ( diff --git a/frontend/src/features/admin/shows/api.ts b/frontend/src/features/admin/shows/api.ts index 7688ef7..ab3ed5c 100644 --- a/frontend/src/features/admin/shows/api.ts +++ b/frontend/src/features/admin/shows/api.ts @@ -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(`/admin/metadata/shows/${showId}/refresh-episodes`, { method: 'POST' }) diff --git a/frontend/src/features/streaming/AirPage.tsx b/frontend/src/features/streaming/AirPage.tsx index 483ff70..abd23f8 100644 --- a/frontend/src/features/streaming/AirPage.tsx +++ b/frontend/src/features/streaming/AirPage.tsx @@ -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() {
{current && (
- {currentEntry?.episodeHasStill && currentEntry.episodeId ? ( + {currentEntry?.episodeStillImageId ? ( diff --git a/frontend/src/features/streaming/api.ts b/frontend/src/features/streaming/api.ts index 5c0ba79..dc616ee 100644 --- a/frontend/src/features/streaming/api.ts +++ b/frontend/src/features/streaming/api.ts @@ -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('/channels') diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index ed62275..eee8100 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -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 } diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index cc1753c..e006c32 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -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',