From a970eae7d20d51d15a1288e7446a628a9a19214e Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 25 Jul 2026 11:43:17 +0300 Subject: [PATCH] Implement image management enhancements: add functionality to relocate legacy images during startup, update image handling in show metadata, and refactor related API endpoints to utilize the new image storage system. Adjust database schema to support image references and update UI components for improved image selection and display. --- .../Endpoints/MetadataEndpoints.cs | 70 +- backend/src/TeleWave.Api/Program.cs | 1 + .../Broadcast/Scheduling/ScheduleGenerator.cs | 39 +- .../Common/Interfaces/IImageDownloader.cs | 10 + .../Common/Interfaces/IMetadataImageStore.cs | 17 - .../Library/GetShow/GetShowQueryHandler.cs | 2 +- .../ListShows/ListShowsQueryHandler.cs | 2 +- .../TeleWave.Application/Library/ShowDtos.cs | 2 +- .../ApplyShowMetadataCommandHandler.cs | 29 +- .../ClearShowMetadataCommandHandler.cs | 8 +- .../SetShowPoster/SetShowPosterCommand.cs | 4 +- .../SetShowPosterCommandHandler.cs | 2 +- .../GetPublicEpg/GetPublicEpgQueryHandler.cs | 4 +- .../ListPublicChannelsQueryHandler.cs | 4 +- .../Streaming/StreamingDtos.cs | 4 +- backend/src/TeleWave.Domain/Library/Show.cs | 20 +- .../DependencyInjection.cs | 1 + .../Media/ImageDownloader.cs | 38 + .../Metadata/MetadataImageStore.cs | 58 -- ...20260725083827_ShowPosterImage.Designer.cs | 889 ++++++++++++++++++ .../20260725083827_ShowPosterImage.cs | 78 ++ .../Migrations/AppDbContextModelSnapshot.cs | 6 +- .../Configurations/ShowConfiguration.cs | 2 +- .../Persistence/MigrationExtensions.cs | 44 + .../features/admin/shows/ShowMetadataCard.tsx | 35 +- frontend/src/features/admin/shows/api.ts | 9 +- frontend/src/features/streaming/AirPage.tsx | 10 +- frontend/src/features/streaming/api.ts | 7 +- frontend/src/shared/api/types.ts | 6 +- frontend/src/shared/lib/i18n.ts | 2 + 30 files changed, 1231 insertions(+), 172 deletions(-) create mode 100644 backend/src/TeleWave.Application/Common/Interfaces/IImageDownloader.cs create mode 100644 backend/src/TeleWave.Infrastructure/Media/ImageDownloader.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725083827_ShowPosterImage.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725083827_ShowPosterImage.cs diff --git a/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs index d3b7671..0e5b2ee 100644 --- a/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs @@ -2,6 +2,8 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; using TeleWave.Api.Common; using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Images.DeleteImage; +using TeleWave.Application.Images.UploadImage; using TeleWave.Application.Metadata; using TeleWave.Application.Metadata.ApplyShowMetadata; using TeleWave.Application.Metadata.ClearShowMetadata; @@ -10,6 +12,7 @@ using TeleWave.Application.Metadata.RefreshEpisodes; using TeleWave.Application.Metadata.SearchShows; using TeleWave.Application.Metadata.SetShowPoster; using TeleWave.Application.Metadata.UpdateShowMetadata; +using TeleWave.Domain.Images; using TeleWave.Infrastructure.Identity; namespace TeleWave.Api.Endpoints; @@ -40,12 +43,13 @@ public static class MetadataEndpoints admin.MapPut("/shows/{showId:guid}", Update).Produces(StatusCodes.Status204NoContent); admin.MapDelete("/shows/{showId:guid}", Clear).Produces(StatusCodes.Status204NoContent); admin.MapPut("/shows/{showId:guid}/poster", UploadPoster).Produces(StatusCodes.Status204NoContent); + admin + .MapPut("/shows/{showId:guid}/poster-image", SetPosterImage) + .Produces(StatusCodes.Status204NoContent); admin.MapPost("/shows/{showId:guid}/refresh-episodes", RefreshEpisodes).Produces(); - // Постеры/кадры отдаём публично (просто картинки, id не угадать) — чтобы работал . - app.MapGet("/api/metadata/shows/{showId:guid}/poster", ServePoster) - .WithTags("Metadata") - .Produces(StatusCodes.Status200OK); + // Кадры серий отдаём публично (просто картинки, id не угадать) — чтобы работал . + // Постеры шоу теперь в общем реестре и отдаются по /api/images/{id}. app.MapGet("/api/metadata/episodes/{episodeId:guid}/still", ServeStill) .WithTags("Metadata") .Produces(StatusCodes.Status200OK); @@ -112,7 +116,7 @@ public static class MetadataEndpoints Guid showId, string fileName, HttpRequest request, - IMetadataImageStore imageStore, + IImageStore imageStore, ISender sender, CancellationToken cancellationToken ) @@ -124,10 +128,44 @@ public static class MetadataEndpoints ) return MetadataErrors.InvalidPoster.ToProblem(); - var relative = await imageStore.SaveShowPosterAsync(showId, ext, request.Body, cancellationToken); - var result = await sender.Send(new SetShowPosterCommand(showId, relative), cancellationToken); + // Регистрируем постер в общем реестре (категория ShowPoster) и привязываем к шоу. + var created = await sender.Send( + new UploadImageCommand(ImageCategory.ShowPoster, ext, fileName), + cancellationToken + ); + if (!created.IsSuccess) + return created.ToHttpResult(); + + try + { + await imageStore.SaveAsync(created.Value, ext, request.Body, cancellationToken); + } + catch + { + await sender.Send(new DeleteImageCommand(created.Value), cancellationToken); + throw; + } + + var result = await sender.Send( + new SetShowPosterCommand(showId, created.Value), + cancellationToken + ); if (!result.IsSuccess) - imageStore.DeleteShowImages(showId); + await sender.Send(new DeleteImageCommand(created.Value), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task SetPosterImage( + Guid showId, + SetPosterImageBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new SetShowPosterCommand(showId, body.ImageId), + cancellationToken + ); return result.ToHttpResult(); } @@ -144,20 +182,6 @@ public static class MetadataEndpoints return result.ToHttpResult(); } - private static async Task ServePoster( - Guid showId, - IAppDbContext dbContext, - IMetadataImageStore imageStore, - CancellationToken cancellationToken - ) - { - var path = await dbContext.Shows.AsNoTracking() - .Where(s => s.Id == showId) - .Select(s => s.PosterPath) - .FirstOrDefaultAsync(cancellationToken); - return ServeImage(path, imageStore); - } - private static async Task ServeStill( Guid episodeId, IAppDbContext dbContext, @@ -193,3 +217,5 @@ public static class MetadataEndpoints public sealed record ApplyMetadataBody(string Provider, string ExternalId); public sealed record UpdateMetadataBody(string? Description, int? Year); + +public sealed record SetPosterImageBody(Guid? ImageId); diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs index 8695018..9e1f737 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -93,6 +93,7 @@ var app = builder.Build(); // Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте. await app.Services.ApplyMigrationsAsync(); +await app.Services.RelocateLegacyImagesAsync(); await app.Services.SeedDataAsync(); app.UseForwardedHeaders(); diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs index c34bc33..c5abbd9 100644 --- a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs @@ -24,7 +24,7 @@ public sealed class ScheduleGenerator( IRandomSource random, IBumperRenderer bumperRenderer, IBumperTemplateStorage bumperStorage, - IMetadataImageStore metadataImages, + IImageStore imageStore, IOptions options, IOptions bumperOptions, IOptions streamingOptions, @@ -196,12 +196,25 @@ public sealed class ScheduleGenerator( var fromIds = combos.Select(c => c.From).Distinct().ToList(); var toIds = combos.Select(c => c.To).Distinct().ToList(); - // Постеры шоу-получателей — как фон заставки (если у блока нет своей фон-картинки). + // Постеры шоу-получателей (из реестра изображений) — как фон заставки, если у блока нет + // своей фон-картинки. Резолвим id постера → расширение → абсолютный путь. var showIds = fromIds.Concat(toIds).Distinct().ToList(); - var posterByShow = await dbContext.Shows.AsNoTracking() - .Where(s => showIds.Contains(s.Id) && s.PosterPath != null) - .Select(s => new { s.Id, s.PosterPath }) - .ToDictionaryAsync(s => s.Id, s => s.PosterPath!, cancellationToken); + var posterShows = await dbContext.Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id) && s.PosterImageId != null) + .Select(s => new { s.Id, ImageId = s.PosterImageId!.Value }) + .ToListAsync(cancellationToken); + var posterImageIds = posterShows.Select(p => p.ImageId).Distinct().ToList(); + var posterExtById = await dbContext.Images.AsNoTracking() + .Where(i => posterImageIds.Contains(i.Id)) + .Select(i => new { i.Id, i.FileExtension }) + .ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken); + var posterByShow = new Dictionary(); + foreach (var p in posterShows) + if ( + posterExtById.TryGetValue(p.ImageId, out var ext) + && imageStore.ResolvePath(p.ImageId, ext) is { } abs + ) + posterByShow[p.Id] = (p.ImageId, abs); // Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить). var cached = await dbContext.BumperAssets.AsNoTracking() @@ -229,9 +242,11 @@ public sealed class ScheduleGenerator( var fromName = showNames.GetValueOrDefault(combo.From, "…"); var toName = showNames.GetValueOrDefault(combo.To, "…"); - var toPosterRel = posterByShow.GetValueOrDefault(combo.To); + var poster = posterByShow.TryGetValue(combo.To, out var pr) ? pr : default; + var posterToken = poster.ImageId == Guid.Empty ? "-" : poster.ImageId.ToString(); + var posterAbs = poster.ImageId == Guid.Empty ? null : poster.AbsPath; var aligned = AlignedDurationSeconds(TemplateDurationSeconds(template)); - var signature = ComputeSignature(channel, template, fromName, toName, aligned, toPosterRel ?? "-"); + var signature = ComputeSignature(channel, template, fromName, toName, aligned, posterToken); var hit = cached.FirstOrDefault(c => c.FromShowId == combo.From @@ -256,7 +271,7 @@ public sealed class ScheduleGenerator( toName, aligned, signature, - toPosterRel, + posterAbs, cancellationToken ); result[combo] = assetId; @@ -284,14 +299,12 @@ public sealed class ScheduleGenerator( string toName, int alignedDurationSeconds, string signature, - string? toPosterRelative, + string? posterAbsolutePath, CancellationToken cancellationToken ) { var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}"); - var posterAbs = toPosterRelative is null - ? null - : metadataImages.ResolveAbsolutePath(toPosterRelative); + var posterAbs = posterAbsolutePath; var render = await bumperRenderer.RenderAsync( asset.Id, BuildSpec(channel, template, alignedDurationSeconds, fromName, toName, posterAbs), diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IImageDownloader.cs b/backend/src/TeleWave.Application/Common/Interfaces/IImageDownloader.cs new file mode 100644 index 0000000..a5b8699 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IImageDownloader.cs @@ -0,0 +1,10 @@ +namespace TeleWave.Application.Common.Interfaces; + +/// Скачанное изображение: содержимое + расширение (с точкой, нижний регистр). +public sealed record DownloadedImage(byte[] Content, string Extension); + +/// Скачивание изображения по URL (например постера/кадра из метаданных) для реестра. +public interface IImageDownloader +{ + Task DownloadAsync(string url, CancellationToken cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IMetadataImageStore.cs b/backend/src/TeleWave.Application/Common/Interfaces/IMetadataImageStore.cs index b817cda..7286408 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IMetadataImageStore.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMetadataImageStore.cs @@ -6,23 +6,6 @@ namespace TeleWave.Application.Common.Interfaces; /// public interface IMetadataImageStore { - /// Скачивает постер по URL и сохраняет для шоу. Возвращает относительный путь или null при ошибке. - Task DownloadShowPosterAsync( - Guid showId, - string url, - CancellationToken cancellationToken - ); - - /// Сохраняет загруженный вручную постер шоу. Возвращает относительный путь. - Task SaveShowPosterAsync( - Guid showId, - string extension, - Stream content, - CancellationToken cancellationToken - ); - - void DeleteShowImages(Guid showId); - /// Скачивает кадр серии по URL. Возвращает относительный путь или null при ошибке. Task DownloadEpisodeStillAsync( Guid episodeId, diff --git a/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs b/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs index 4668a8d..74fac74 100644 --- a/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs +++ b/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs @@ -60,7 +60,7 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext) show.MetadataProvider, show.MetadataExternalId, show.Year, - show.PosterPath is not null, + show.PosterImageId, episodeDtos ) ); diff --git a/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs index 8802f7b..ecf3be6 100644 --- a/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs +++ b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs @@ -39,7 +39,7 @@ public sealed class ListShowsQueryHandler(IAppDbContext dbContext) s.Episodes.Count, seasons, s.Year, - s.PosterPath is not null, + s.PosterImageId is not null, s.CreatedAt ); }) diff --git a/backend/src/TeleWave.Application/Library/ShowDtos.cs b/backend/src/TeleWave.Application/Library/ShowDtos.cs index 6d67399..b9df1d9 100644 --- a/backend/src/TeleWave.Application/Library/ShowDtos.cs +++ b/backend/src/TeleWave.Application/Library/ShowDtos.cs @@ -38,6 +38,6 @@ public sealed record ShowDto( string? MetadataProvider, string? MetadataExternalId, int? Year, - bool HasPoster, + Guid? PosterImageId, IReadOnlyList Episodes ); diff --git a/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs b/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs index 5749db2..73ac261 100644 --- a/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs +++ b/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.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.ApplyShowMetadata; public sealed class ApplyShowMetadataCommandHandler( IAppDbContext dbContext, IMetadataProviderResolver resolver, - IMetadataImageStore imageStore + IImageDownloader downloader, + IImageStore imageStore ) : ICommandHandler { public async Task Handle( @@ -32,15 +34,26 @@ public sealed class ApplyShowMetadataCommandHandler( if (meta is null) return Result.Failure(MetadataErrors.NotFound); - string? posterPath = null; + // Постер скачиваем и регистрируем в общем реестре изображений (галерея). + Guid? posterImageId = null; if (!string.IsNullOrEmpty(meta.PosterUrl)) - posterPath = await imageStore.DownloadShowPosterAsync( - show.Id, - meta.PosterUrl, - cancellationToken - ); + { + var downloaded = await downloader.DownloadAsync(meta.PosterUrl, cancellationToken); + if (downloaded is not null) + { + var image = Image.Create(ImageCategory.ShowPoster, downloaded.Extension, show.Name); + dbContext.Images.Add(image); + await imageStore.SaveAsync( + image.Id, + downloaded.Extension, + downloaded.Content, + cancellationToken + ); + posterImageId = image.Id; + } + } - show.ApplyMetadata(command.Provider, meta.ExternalId, meta.Overview, meta.Year, posterPath); + show.ApplyMetadata(command.Provider, meta.ExternalId, meta.Overview, meta.Year, posterImageId); return Result.Success(); } } diff --git a/backend/src/TeleWave.Application/Metadata/ClearShowMetadata/ClearShowMetadataCommandHandler.cs b/backend/src/TeleWave.Application/Metadata/ClearShowMetadata/ClearShowMetadataCommandHandler.cs index cd6b7a2..8b19643 100644 --- a/backend/src/TeleWave.Application/Metadata/ClearShowMetadata/ClearShowMetadataCommandHandler.cs +++ b/backend/src/TeleWave.Application/Metadata/ClearShowMetadata/ClearShowMetadataCommandHandler.cs @@ -6,10 +6,8 @@ using TeleWave.Application.Library; namespace TeleWave.Application.Metadata.ClearShowMetadata; -public sealed class ClearShowMetadataCommandHandler( - IAppDbContext dbContext, - IMetadataImageStore imageStore -) : ICommandHandler +public sealed class ClearShowMetadataCommandHandler(IAppDbContext dbContext) + : ICommandHandler { public async Task Handle( ClearShowMetadataCommand command, @@ -23,7 +21,7 @@ public sealed class ClearShowMetadataCommandHandler( if (show is null) return Result.Failure(ShowErrors.NotFound); - imageStore.DeleteShowImages(show.Id); + // Отвязываем постер; сама картинка остаётся в галерее (удаляется отдельно из неё). show.ClearMetadata(); return Result.Success(); } diff --git a/backend/src/TeleWave.Application/Metadata/SetShowPoster/SetShowPosterCommand.cs b/backend/src/TeleWave.Application/Metadata/SetShowPoster/SetShowPosterCommand.cs index bdccc0d..1100c60 100644 --- a/backend/src/TeleWave.Application/Metadata/SetShowPoster/SetShowPosterCommand.cs +++ b/backend/src/TeleWave.Application/Metadata/SetShowPoster/SetShowPosterCommand.cs @@ -3,5 +3,5 @@ using TeleWave.Application.Common.Models; namespace TeleWave.Application.Metadata.SetShowPoster; -/// Привязать к шоу загруженный вручную постер (файл уже сохранён хранилищем). -public sealed record SetShowPosterCommand(Guid ShowId, string PosterPath) : ICommand; +/// Привязать/снять постер шоу по ссылке на запись реестра изображений (null — отвязать). +public sealed record SetShowPosterCommand(Guid ShowId, Guid? ImageId) : ICommand; diff --git a/backend/src/TeleWave.Application/Metadata/SetShowPoster/SetShowPosterCommandHandler.cs b/backend/src/TeleWave.Application/Metadata/SetShowPoster/SetShowPosterCommandHandler.cs index a4b71e7..86c2a70 100644 --- a/backend/src/TeleWave.Application/Metadata/SetShowPoster/SetShowPosterCommandHandler.cs +++ b/backend/src/TeleWave.Application/Metadata/SetShowPoster/SetShowPosterCommandHandler.cs @@ -21,7 +21,7 @@ public sealed class SetShowPosterCommandHandler(IAppDbContext dbContext) if (show is null) return Result.Failure(ShowErrors.NotFound); - show.SetPosterPath(command.PosterPath); + show.SetPosterImage(command.ImageId); return Result.Success(); } } diff --git a/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQueryHandler.cs b/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQueryHandler.cs index ed3c7f7..0955fe2 100644 --- a/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQueryHandler.cs +++ b/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQueryHandler.cs @@ -41,7 +41,7 @@ public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext) var showIds = entries.Where(e => e.ShowId != null).Select(e => e.ShowId!.Value).Distinct().ToList(); var shows = await dbContext.Shows.AsNoTracking() .Where(s => showIds.Contains(s.Id)) - .Select(s => new { s.Id, s.Name, s.PosterPath }) + .Select(s => new { s.Id, s.Name, s.PosterImageId }) .ToDictionaryAsync(s => s.Id, cancellationToken); // Метаданные серий: ключ — (шоу, ассет). @@ -78,7 +78,7 @@ public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext) e.EndsAtUtc, e.ShowId, show?.Name, - show?.PosterPath is not null, + show?.PosterImageId, episode?.Id, episode?.Title, episode?.Overview, diff --git a/backend/src/TeleWave.Application/Streaming/ListPublicChannels/ListPublicChannelsQueryHandler.cs b/backend/src/TeleWave.Application/Streaming/ListPublicChannels/ListPublicChannelsQueryHandler.cs index 21a2a12..98dc3aa 100644 --- a/backend/src/TeleWave.Application/Streaming/ListPublicChannels/ListPublicChannelsQueryHandler.cs +++ b/backend/src/TeleWave.Application/Streaming/ListPublicChannels/ListPublicChannelsQueryHandler.cs @@ -42,7 +42,7 @@ public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext) var showIds = currentShowByChannel.Values.Distinct().ToList(); var shows = await dbContext.Shows.AsNoTracking() .Where(s => showIds.Contains(s.Id)) - .Select(s => new { s.Id, s.Name, s.PosterPath }) + .Select(s => new { s.Id, s.Name, s.PosterImageId }) .ToDictionaryAsync(s => s.Id, cancellationToken); return channels @@ -56,7 +56,7 @@ public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext) c.Name, show is null ? null : showId, show?.Name, - show?.PosterPath is not null + show?.PosterImageId ); }) .ToList(); diff --git a/backend/src/TeleWave.Application/Streaming/StreamingDtos.cs b/backend/src/TeleWave.Application/Streaming/StreamingDtos.cs index 9e2a08e..84d1482 100644 --- a/backend/src/TeleWave.Application/Streaming/StreamingDtos.cs +++ b/backend/src/TeleWave.Application/Streaming/StreamingDtos.cs @@ -8,7 +8,7 @@ public sealed record PublicChannelDto( string Name, Guid? CurrentShowId, string? CurrentShowName, - bool CurrentShowHasPoster + Guid? CurrentShowPosterImageId ); /// Запись публичного телегида с метаданными (без имён файлов и номеров серий). @@ -18,7 +18,7 @@ public sealed record PublicEpgEntryDto( DateTimeOffset EndsAtUtc, Guid? ShowId, string? ShowName, - bool ShowHasPoster, + Guid? ShowPosterImageId, Guid? EpisodeId, string? EpisodeTitle, string? EpisodeOverview, diff --git a/backend/src/TeleWave.Domain/Library/Show.cs b/backend/src/TeleWave.Domain/Library/Show.cs index 3f4f784..ea31ec1 100644 --- a/backend/src/TeleWave.Domain/Library/Show.cs +++ b/backend/src/TeleWave.Domain/Library/Show.cs @@ -29,8 +29,8 @@ public class Show public int? Year { get; private set; } - /// Относительный путь локального постера от корня хранилища или null. - public string? PosterPath { get; private set; } + /// Постер шоу — ссылка на запись общего реестра изображений (Domain/Images) или null. + public Guid? PosterImageId { get; private set; } /// Серии шоу (backing-field для EF). Порядок показа — по ; /// потребители сортируют явно (см. загрузчик планировщика). @@ -86,13 +86,13 @@ public class Show public bool CanAddEpisode => Kind != ShowKind.Single || _episodes.Count == 0; - /// Применить метаданные из внешнего источника. Постер (уже скачанный локально) может быть null. + /// Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null. public void ApplyMetadata( string provider, string externalId, string? description, int? year, - string? posterPath + Guid? posterImageId ) { MetadataProvider = provider; @@ -100,8 +100,8 @@ public class Show if (!string.IsNullOrWhiteSpace(description)) Description = description; Year = year; - if (posterPath is not null) - PosterPath = posterPath; + if (posterImageId is not null) + PosterImageId = posterImageId; } /// Ручная правка метаданных (без внешнего источника). @@ -113,16 +113,16 @@ public class Show Year = year; } - /// Задать/снять локальный постер (после скачивания/загрузки/удаления файла). - public void SetPosterPath(string? path) => PosterPath = path; + /// Привязать/снять постер шоу (ссылка на запись реестра изображений). + public void SetPosterImage(Guid? imageId) => PosterImageId = imageId; - /// Сбросить все метаданные и постер (файл удаляет вызывающий по старому PosterPath). + /// Сбросить все метаданные и отвязать постер (сама картинка остаётся в галерее). public void ClearMetadata() { MetadataProvider = null; MetadataExternalId = null; Year = null; - PosterPath = null; + PosterImageId = null; Description = null; } } diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs index e2e1f90..573845b 100644 --- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -137,6 +137,7 @@ public static class DependencyInjection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/TeleWave.Infrastructure/Media/ImageDownloader.cs b/backend/src/TeleWave.Infrastructure/Media/ImageDownloader.cs new file mode 100644 index 0000000..7be70a8 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/ImageDownloader.cs @@ -0,0 +1,38 @@ +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Infrastructure.Media; + +/// Скачивает изображение по URL через HTTP-клиент «metadata» и определяет расширение. +public sealed class ImageDownloader(IHttpClientFactory httpFactory) : IImageDownloader +{ + public async Task DownloadAsync(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 bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + return bytes.Length == 0 ? null : new DownloadedImage(bytes, ext); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException) + { + return null; + } + } + + private static string ExtensionFor(string url, string? mediaType) => + mediaType switch + { + "image/png" => ".png", + "image/webp" => ".webp", + "image/gif" => ".gif", + "image/jpeg" => ".jpg", + _ => Path.GetExtension(new Uri(url).AbsolutePath) is { Length: > 1 } e + ? e.ToLowerInvariant() + : ".jpg", + }; +} diff --git a/backend/src/TeleWave.Infrastructure/Metadata/MetadataImageStore.cs b/backend/src/TeleWave.Infrastructure/Metadata/MetadataImageStore.cs index e846c08..40ad7cd 100644 --- a/backend/src/TeleWave.Infrastructure/Metadata/MetadataImageStore.cs +++ b/backend/src/TeleWave.Infrastructure/Metadata/MetadataImageStore.cs @@ -7,43 +7,6 @@ namespace TeleWave.Infrastructure.Metadata; public sealed class MetadataImageStore(MediaPathResolver paths, IHttpClientFactory httpFactory) : IMetadataImageStore { - public async Task DownloadShowPosterAsync( - Guid showId, - 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); - await using var content = await response.Content.ReadAsStreamAsync(cancellationToken); - return await WritePosterAsync(showId, ext, content, cancellationToken); - } - catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException) - { - return null; - } - } - - public Task SaveShowPosterAsync( - Guid showId, - string extension, - Stream content, - CancellationToken cancellationToken - ) => WritePosterAsync(showId, NormalizeExtension(extension), content, cancellationToken); - - public void DeleteShowImages(Guid showId) - { - var dir = paths.MetadataShowDir(showId); - if (Directory.Exists(dir)) - Directory.Delete(dir, recursive: true); - } - public async Task DownloadEpisodeStillAsync( Guid episodeId, string url, @@ -87,24 +50,6 @@ public sealed class MetadataImageStore(MediaPathResolver paths, IHttpClientFacto return abs is not null && File.Exists(abs) ? abs : null; } - private async Task WritePosterAsync( - Guid showId, - string ext, - Stream content, - CancellationToken cancellationToken - ) - { - var dir = paths.MetadataShowDir(showId); - Directory.CreateDirectory(dir); - RemoveExisting(dir, "poster"); - - var abs = paths.MetadataShowPosterPath(showId, ext); - await using (var fs = File.Create(abs)) - await content.CopyToAsync(fs, cancellationToken); - - return paths.MetadataShowPosterRelative(showId, ext); - } - private static void RemoveExisting(string dir, string baseName) { foreach (var file in Directory.EnumerateFiles(dir, baseName + ".*")) @@ -119,7 +64,4 @@ public sealed class MetadataImageStore(MediaPathResolver paths, IHttpClientFacto "image/jpeg" => ".jpg", _ => Path.GetExtension(new Uri(url).AbsolutePath) is { Length: > 1 } e ? e.ToLowerInvariant() : ".jpg", }; - - private static string NormalizeExtension(string extension) => - extension.StartsWith('.') ? extension.ToLowerInvariant() : "." + extension.ToLowerInvariant(); } diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725083827_ShowPosterImage.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725083827_ShowPosterImage.Designer.cs new file mode 100644 index 0000000..c0f6b84 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725083827_ShowPosterImage.Designer.cs @@ -0,0 +1,889 @@ +// +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("20260725083827_ShowPosterImage")] + partial class ShowPosterImage + { + /// + 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("BackgroundImageExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + 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("StillPath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + 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/20260725083827_ShowPosterImage.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725083827_ShowPosterImage.cs new file mode 100644 index 0000000..ee8152d --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725083827_ShowPosterImage.cs @@ -0,0 +1,78 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class ShowPosterImage : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "OriginalName", + table: "Shows", + type: "character varying(256)", + maxLength: 256, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "PosterImageId", + table: "Shows", + type: "uuid", + nullable: true); + + // Переносим существующие постеры шоу в общий реестр изображений: на каждый постер — + // запись Images (Category=1 ShowPoster) с расширением из старого пути; файлы перекладывает + // startup-шаг RelocateLegacyImagesAsync. Затем удаляем колонку PosterPath. + migrationBuilder.Sql( + """ + DO $$ + DECLARE r RECORD; img uuid; + BEGIN + FOR r IN SELECT "Id", "PosterPath" FROM "Shows" WHERE "PosterPath" IS NOT NULL LOOP + img := gen_random_uuid(); + INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt") + VALUES (img, 1, lower(coalesce(substring(r."PosterPath" from '\.[^.]*$'), '.jpg')), 'poster', now()); + UPDATE "Shows" SET "PosterImageId" = img WHERE "Id" = r."Id"; + END LOOP; + END $$; + """ + ); + + migrationBuilder.DropColumn( + name: "PosterPath", + table: "Shows"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PosterImageId", + table: "Shows"); + + migrationBuilder.AlterColumn( + name: "OriginalName", + table: "Shows", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(256)", + oldMaxLength: 256, + oldNullable: true); + + migrationBuilder.AddColumn( + name: "PosterPath", + table: "Shows", + type: "character varying(256)", + maxLength: 256, + nullable: true); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 383a1e5..951bab5 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -510,12 +510,12 @@ namespace TeleWave.Infrastructure.Migrations .HasColumnType("character varying(256)"); b.Property("OriginalName") - .HasColumnType("text"); - - b.Property("PosterPath") .HasMaxLength(256) .HasColumnType("character varying(256)"); + b.Property("PosterImageId") + .HasColumnType("uuid"); + b.Property("Year") .HasColumnType("integer"); diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs index 3ba148b..2f20b24 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs @@ -13,7 +13,7 @@ public class ShowConfiguration : IEntityTypeConfiguration builder.Property(x => x.Description).HasMaxLength(2048); builder.Property(x => x.MetadataProvider).HasMaxLength(16); builder.Property(x => x.MetadataExternalId).HasMaxLength(64); - builder.Property(x => x.PosterPath).HasMaxLength(256); + builder.Property(x => x.OriginalName).HasMaxLength(256); builder .HasMany(x => x.Episodes) diff --git a/backend/src/TeleWave.Infrastructure/Persistence/MigrationExtensions.cs b/backend/src/TeleWave.Infrastructure/Persistence/MigrationExtensions.cs index 930094a..8cfd2fa 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/MigrationExtensions.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/MigrationExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using TeleWave.Infrastructure.Media; namespace TeleWave.Infrastructure.Persistence; @@ -15,4 +16,47 @@ public static class MigrationExtensions var dbContext = scope.ServiceProvider.GetRequiredService(); await dbContext.Database.MigrateAsync(cancellationToken); } + + /// + /// Идемпотентно переносит файлы постеров шоу, мигрированных в реестр изображений, из старого + /// расположения metadata/shows/{showId}/poster{ext} в images/{imageId}{ext}. Безопасно к повторным + /// запускам (пропускает, если целевой файл уже на месте). + /// + public static async Task RelocateLegacyImagesAsync( + this IServiceProvider services, + CancellationToken cancellationToken = default + ) + { + await using var scope = services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var paths = scope.ServiceProvider.GetRequiredService(); + + 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, + } + ) + .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; + + Directory.CreateDirectory(paths.ImagesDir); + File.Move(legacy, target); + } + } } diff --git a/frontend/src/features/admin/shows/ShowMetadataCard.tsx b/frontend/src/features/admin/shows/ShowMetadataCard.tsx index c90e6a2..1736745 100644 --- a/frontend/src/features/admin/shows/ShowMetadataCard.tsx +++ b/frontend/src/features/admin/shows/ShowMetadataCard.tsx @@ -9,6 +9,8 @@ import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { toast } from '@/shared/ui/toast-store' +import { imageUrl } from '@/features/admin/images/api' +import { ImageGallery } from '@/features/admin/images/ImageGallery' import { applyMetadata, clearMetadata, @@ -16,7 +18,7 @@ import { refreshEpisodesMetadata, searchMetadata, setShowOriginalName, - showPosterUrl, + setShowPoster, updateMetadata, uploadPoster, } from './api' @@ -24,7 +26,7 @@ import { export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged: () => void }) { const { t } = useTranslation() const posterInput = useRef(null) - const [bust, setBust] = useState(0) + const [galleryOpen, setGalleryOpen] = useState(false) const [provider, setProvider] = useState('') const [originalName, setOriginalName] = useState(show.originalName ?? '') const [query, setQuery] = useState(show.originalName || show.name) @@ -40,10 +42,16 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged const onError = (error: unknown) => toast.error(error instanceof HttpError ? error.detail : t('common.error')) - const changed = () => { - setBust(Date.now()) - onChanged() - } + const changed = () => onChanged() + + const setPoster = useMutation({ + mutationFn: (imageId: string) => setShowPoster(show.id, imageId), + onSuccess: () => { + toast.success(t('settings.saved')) + changed() + }, + onError, + }) const effectiveProvider = provider || providers?.[0] || '' @@ -121,9 +129,9 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged {/* Постер */}
- {show.hasPoster ? ( + {show.posterImageId ? ( @@ -150,6 +158,15 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged > {t('admin.metadata.uploadPoster')} + + setPoster.mutate(img.id)} + />
{/* Поиск + ручная правка */} @@ -282,7 +299,7 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged : t('admin.metadata.refreshEpisodes')} )} - {(show.metadataProvider || show.hasPoster) && ( + {(show.metadataProvider || show.posterImageId) && (