From 7efd616c295a14c173f6627588be9f47aec7c0fd Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 25 Jul 2026 11:56:36 +0300 Subject: [PATCH] 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. --- .../Endpoints/ChannelEndpoints.cs | 34 +- .../Endpoints/MetadataEndpoints.cs | 39 +- ...rBumperTemplateBackgroundCommandHandler.cs | 8 +- .../RenderBumperPreviewQueryHandler.cs | 15 +- .../SetBumperTemplateBackgroundCommand.cs | 4 +- ...tBumperTemplateBackgroundCommandHandler.cs | 2 +- .../Broadcast/ChannelDtos.cs | 2 +- .../GetChannel/GetChannelQueryHandler.cs | 2 +- .../Broadcast/Scheduling/ScheduleGenerator.cs | 40 +- .../Interfaces/IBumperTemplateStorage.cs | 15 +- .../Common/Interfaces/IMetadataImageStore.cs | 20 - .../Library/GetShow/GetShowQueryHandler.cs | 2 +- .../TeleWave.Application/Library/ShowDtos.cs | 2 +- ...freshShowEpisodesMetadataCommandHandler.cs | 32 +- .../GetPublicEpg/GetPublicEpgQueryHandler.cs | 4 +- .../Streaming/StreamingDtos.cs | 2 +- .../Broadcast/BumperTemplate.cs | 16 +- .../TeleWave.Domain/Library/ShowEpisode.cs | 14 +- .../DependencyInjection.cs | 1 - .../Media/BumperTemplateStorage.cs | 17 +- .../Metadata/MetadataImageStore.cs | 67 -- ..._EpisodeStillAndBumperBgImages.Designer.cs | 887 ++++++++++++++++++ ...725085149_EpisodeStillAndBumperBgImages.cs | 93 ++ .../Migrations/AppDbContextModelSnapshot.cs | 10 +- .../Configurations/ChannelConfiguration.cs | 1 - .../Configurations/ShowConfiguration.cs | 1 - .../Persistence/MigrationExtensions.cs | 55 +- .../features/admin/channels/ChannelDetail.tsx | 81 +- frontend/src/features/admin/channels/api.ts | 8 +- .../src/features/admin/shows/ShowDetail.tsx | 7 +- frontend/src/features/admin/shows/api.ts | 5 - frontend/src/features/streaming/AirPage.tsx | 6 +- frontend/src/features/streaming/api.ts | 6 +- frontend/src/shared/api/types.ts | 6 +- frontend/src/shared/lib/i18n.ts | 2 + 35 files changed, 1227 insertions(+), 279 deletions(-) delete mode 100644 backend/src/TeleWave.Application/Common/Interfaces/IMetadataImageStore.cs delete mode 100644 backend/src/TeleWave.Infrastructure/Metadata/MetadataImageStore.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725085149_EpisodeStillAndBumperBgImages.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725085149_EpisodeStillAndBumperBgImages.cs 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',