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) && (