diff --git a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs index f44d391..739413d 100644 --- a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs @@ -2,6 +2,7 @@ using LiteCqrs; using TeleWave.Api.Common; using TeleWave.Application.Library; using TeleWave.Application.Library.AddEpisode; +using TeleWave.Application.Library.BulkTagShows; using TeleWave.Application.Library.CreateShow; using TeleWave.Application.Library.DeleteShow; using TeleWave.Application.Library.GetShow; @@ -11,6 +12,7 @@ using TeleWave.Application.Library.RenameShow; using TeleWave.Application.Library.SetShowAudience; using TeleWave.Application.Library.SetShowGenres; using TeleWave.Application.Library.SetShowOriginalName; +using TeleWave.Application.Metadata.EnrichShows; using TeleWave.Domain.Library; using TeleWave.Infrastructure.Identity; @@ -34,6 +36,10 @@ public static class ShowEndpoints admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent); admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent); admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent); + + // Массовые операции над отмеченными шоу: разметка руками и обогащение из источника. + admin.MapPost("/bulk/tag", BulkTag).Produces(); + admin.MapPost("/bulk/enrich", BulkEnrich).Produces(); admin .MapPost("/{id:guid}/episodes", AddEpisode) .Produces(StatusCodes.Status201Created); @@ -172,10 +178,58 @@ public static class ShowEndpoints var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken); return result.ToHttpResult(); } + + private static async Task BulkTag( + BulkTagShowsBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new BulkTagShowsCommand( + body.ShowIds, + body.GenreIds, + body.ReplaceGenres, + body.Audience, + body.SetAudience + ), + cancellationToken + ); + return result.IsSuccess + ? Results.Ok(new BulkTaggedResponse(result.Value)) + : result.ToHttpResult(); + } + + private static async Task BulkEnrich( + BulkEnrichBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new EnrichShowsMetadataCommand(body.ShowIds, body.Provider), + cancellationToken + ); + return result.ToHttpResult(); + } } public sealed record AddEpisodeBody(Guid MediaAssetId); +/// Массовая разметка: жанры и рейтинг применяются независимо друг от друга. +public sealed record BulkTagShowsBody( + IReadOnlyList ShowIds, + IReadOnlyList? GenreIds = null, + bool ReplaceGenres = false, + ShowAudience? Audience = null, + bool SetAudience = false +); + +public sealed record BulkEnrichBody(IReadOnlyList ShowIds, string Provider); + +/// Скольких шоу коснулась массовая правка. +public sealed record BulkTaggedResponse(int Updated); + public sealed record RenameShowBody(string Name); public sealed record SetShowOriginalNameBody(string? OriginalName); diff --git a/backend/src/TeleWave.Application/DependencyInjection.cs b/backend/src/TeleWave.Application/DependencyInjection.cs index ca3369a..40e36b6 100644 --- a/backend/src/TeleWave.Application/DependencyInjection.cs +++ b/backend/src/TeleWave.Application/DependencyInjection.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Common.Behaviors; using TeleWave.Application.Library.Genres; +using TeleWave.Application.Metadata; using TeleWave.Application.Programming.Groups; using TeleWave.Application.Programming.Groups.Suggest; using TeleWave.Application.Programming.Planning; @@ -36,6 +37,7 @@ public static class DependencyInjection // здесь, а не в Infrastructure: тот слой не должен знать внутреннего устройства Application. services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/TeleWave.Application/Library/BulkTagShows/BulkTagShowsCommand.cs b/backend/src/TeleWave.Application/Library/BulkTagShows/BulkTagShowsCommand.cs new file mode 100644 index 0000000..640b3f7 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/BulkTagShows/BulkTagShowsCommand.cs @@ -0,0 +1,28 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Library.BulkTagShows; + +/// +/// Проставляет жанры и/или возрастной рейтинг сразу нескольким шоу. Оба поля необязательны и +/// применяются независимо: обычно правят что-то одно, а связывать их в одну форму значило бы +/// заставлять переставлять второе «как было». +/// +/// +/// Что делать с жанрами: null — не трогать. Иначе решает, заменить +/// набор целиком или дописать к имеющимся. +/// +/// +/// true — заменить набор жанров переданным; false — дописать. Дописывание нужно чаще: у шоу уже +/// есть жанры из метаданных, и разметка «это ещё и детское» не должна их стирать. +/// +/// Рейтинг: null со = true снимает проставленный. +/// Трогать ли рейтинг вообще. +public sealed record BulkTagShowsCommand( + IReadOnlyList ShowIds, + IReadOnlyList? GenreIds = null, + bool ReplaceGenres = false, + ShowAudience? Audience = null, + bool SetAudience = false +) : ICommand>; diff --git a/backend/src/TeleWave.Application/Library/BulkTagShows/BulkTagShowsCommandHandler.cs b/backend/src/TeleWave.Application/Library/BulkTagShows/BulkTagShowsCommandHandler.cs new file mode 100644 index 0000000..3d2c1e9 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/BulkTagShows/BulkTagShowsCommandHandler.cs @@ -0,0 +1,66 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Library.Genres; + +namespace TeleWave.Application.Library.BulkTagShows; + +public sealed class BulkTagShowsCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public async Task> Handle( + BulkTagShowsCommand command, + CancellationToken cancellationToken + ) + { + var showIds = command.ShowIds.Distinct().ToList(); + if (showIds.Count == 0) + return Result.Success(0); + + var genreIds = command.GenreIds?.Distinct().ToList(); + if (genreIds is { Count: > 0 }) + { + // Ссылка на несуществующий жанр упала бы нарушением внешнего ключа — проверяем заранее. + var known = await dbContext + .Genres.Where(g => genreIds.Contains(g.Id)) + .CountAsync(cancellationToken); + if (known != genreIds.Count) + return Result.Failure(GenreErrors.NotFound); + } + + var shows = await dbContext + .Shows.Include(s => s.Genres) + .Where(s => showIds.Contains(s.Id)) + .ToListAsync(cancellationToken); + + // Пропавшее шоу не роняет всю операцию: список выбирали галочками, и пока его собирали, + // что-то могли удалить. Возвращаем, скольких реально коснулись. + foreach (var show in shows) + { + if (genreIds is not null) + ApplyGenres(show, genreIds, command.ReplaceGenres); + + if (command.SetAudience) + show.SetAudience(command.Audience); + } + + return Result.Success(shows.Count); + } + + /// + /// Основным остаётся прежний основной жанр — массовая разметка не должна незаметно менять то, + /// что показано в списке шоу. Если его не было (жанров не было вовсе), основным станет первый + /// из добавленных, как и при обычной правке. + /// + private static void ApplyGenres( + Domain.Library.Show show, + IReadOnlyList genreIds, + bool replace + ) + { + var primary = show.PrimaryGenreId; + var ids = replace ? genreIds : show.Genres.Select(g => g.GenreId).Union(genreIds).ToList(); + show.SetGenres(ids, ids.Contains(primary ?? Guid.Empty) ? primary : null); + } +} diff --git a/backend/src/TeleWave.Application/Library/BulkTagShows/BulkTagShowsCommandValidator.cs b/backend/src/TeleWave.Application/Library/BulkTagShows/BulkTagShowsCommandValidator.cs new file mode 100644 index 0000000..59a318a --- /dev/null +++ b/backend/src/TeleWave.Application/Library/BulkTagShows/BulkTagShowsCommandValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace TeleWave.Application.Library.BulkTagShows; + +public sealed class BulkTagShowsCommandValidator : AbstractValidator +{ + public BulkTagShowsCommandValidator() + { + RuleFor(x => x.ShowIds).NotEmpty(); + // Команда, которая ничего не меняет, — почти наверняка недосмотр вызывающей стороны. + RuleFor(x => x) + .Must(x => x.GenreIds is not null || x.SetAudience) + .WithMessage("Нечего применять: не заданы ни жанры, ни рейтинг."); + } +} diff --git a/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs b/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs index 706f734..8ccfa8d 100644 --- a/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs +++ b/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs @@ -3,18 +3,12 @@ using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; using TeleWave.Application.Library; -using TeleWave.Application.Library.Genres; -using TeleWave.Domain.Images; -using TeleWave.Domain.Library; namespace TeleWave.Application.Metadata.ApplyShowMetadata; public sealed class ApplyShowMetadataCommandHandler( IAppDbContext dbContext, - IMetadataProviderResolver resolver, - IImageDownloader downloader, - IImageStore imageStore, - GenreMatcher genreMatcher + ShowMetadataApplier applier ) : ICommandHandler { public async Task Handle( @@ -28,60 +22,11 @@ public sealed class ApplyShowMetadataCommandHandler( if (show is null) return Result.Failure(ShowErrors.NotFound); - // Ролики метаданными не обогащаются: у них нет ни года, ни постера, а сотня роликов - // в поиске только мешала бы. Раньше это держалось на том, что UI такой кнопки не рисует. - if (show.Kind == ShowKind.Interstitial) - return Result.Failure(MetadataErrors.NotForInterstitials); - - var provider = resolver.Resolve(command.Provider); - if (provider is null) - return Result.Failure(MetadataErrors.ProviderNotAvailable); - - // Тип берём у шоу, а не из команды: иначе клиент смог бы заставить TMDb отдать метаданные - // одноимённого фильма для сериала — идентификаторы у них независимые. - var meta = await provider.GetShowAsync(command.ExternalId, show.Kind, cancellationToken); - if (meta is null) - return Result.Failure(MetadataErrors.NotFound); - - // Постер скачиваем и регистрируем в общем реестре изображений (галерея). - Guid? posterImageId = null; - if (!string.IsNullOrEmpty(meta.PosterUrl)) - { - 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( + return await applier.ApplyAsync( + show, command.Provider, - meta.ExternalId, - meta.Overview, - meta.Year, - posterImageId + command.ExternalId, + cancellationToken ); - - // Жанры источника перезаписывают текущие, только если хоть один распознан справочником: - // иначе применение метаданных к шоу с руками проставленными жанрами обнуляло бы их. - var genreIds = await genreMatcher.MatchAsync(meta.Genres, cancellationToken); - if (genreIds.Count > 0) - show.SetGenres(genreIds); - - // Рейтинг — по тому же правилу: проставляем только распознанный. «Not Rated», «N/A» и местные - // шкалы означают отсутствие данных, и снимать ими уже проставленный рейтинг нельзя — иначе - // «обновить метаданные» тихо открыло бы шоу дорогу в детское время. - if (ContentRating.Parse(meta.ContentRating) is { } audience) - show.SetAudience(audience); - - return Result.Success(); } } diff --git a/backend/src/TeleWave.Application/Metadata/EnrichShows/EnrichShowsMetadataCommand.cs b/backend/src/TeleWave.Application/Metadata/EnrichShows/EnrichShowsMetadataCommand.cs new file mode 100644 index 0000000..e177b04 --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/EnrichShows/EnrichShowsMetadataCommand.cs @@ -0,0 +1,18 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Metadata.EnrichShows; + +/// +/// Массовое обогащение: найти метаданные по названию и применить — для шоу, у которых источник ещё +/// не привязан. Список шоу приходит явно (отмеченные галочками), а не «все подряд»: автоподбор по +/// названию иногда промахивается, и цена ошибки — чужой постер и чужой рейтинг у шоу в эфире. +/// +public sealed record EnrichShowsMetadataCommand(IReadOnlyList ShowIds, string Provider) + : ICommand>; + +/// Скольким шоу метаданные применены. +/// Что пропущено и почему — по одной строке на шоу. +public sealed record EnrichShowsResultDto(int Applied, IReadOnlyList Skipped); + +public sealed record EnrichSkipDto(Guid ShowId, string ShowName, string Reason); diff --git a/backend/src/TeleWave.Application/Metadata/EnrichShows/EnrichShowsMetadataCommandHandler.cs b/backend/src/TeleWave.Application/Metadata/EnrichShows/EnrichShowsMetadataCommandHandler.cs new file mode 100644 index 0000000..d31f823 --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/EnrichShows/EnrichShowsMetadataCommandHandler.cs @@ -0,0 +1,97 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Metadata.EnrichShows; + +public sealed class EnrichShowsMetadataCommandHandler( + IAppDbContext dbContext, + IMetadataProviderResolver resolver, + ShowMetadataApplier applier +) : ICommandHandler> +{ + public async Task> Handle( + EnrichShowsMetadataCommand command, + CancellationToken cancellationToken + ) + { + var provider = resolver.Resolve(command.Provider); + if (provider is null) + return Result.Failure(MetadataErrors.ProviderNotAvailable); + + var showIds = command.ShowIds.Distinct().ToList(); + var shows = await dbContext + .Shows.Include(s => s.Genres) + .Where(s => showIds.Contains(s.Id) && s.Kind != ShowKind.Interstitial) + .ToListAsync(cancellationToken); + + var applied = 0; + var skipped = new List(); + + foreach (var show in shows) + { + // Уже привязанное не трогаем: массовая операция не должна переписывать выбор, сделанный + // руками. Обновить такое шоу можно точечно на его карточке. + if (!string.IsNullOrEmpty(show.MetadataExternalId)) + { + skipped.Add(Skip(show, "источник уже привязан")); + continue; + } + + // Метаданные ищутся по оригинальному названию — как и в карточке шоу. + var term = string.IsNullOrWhiteSpace(show.OriginalName) ? show.Name : show.OriginalName; + var candidates = await provider.SearchShowsAsync(term, show.Kind, cancellationToken); + + if (Pick(candidates, show) is not { } match) + { + skipped.Add( + Skip( + show, + candidates.Count == 0 ? "ничего не найдено" : "нет точного совпадения" + ) + ); + continue; + } + + var result = await applier.ApplyAsync( + show, + command.Provider, + match.ExternalId, + cancellationToken + ); + if (result.IsSuccess) + applied++; + else + skipped.Add(Skip(show, result.Error.Message)); + } + + return Result.Success(new EnrichShowsResultDto(applied, skipped)); + } + + /// + /// Кандидат берётся, только если название совпало точно (без учёта регистра и пробелов), а при + /// известном годе сходится и он. Брать «первый из выдачи» нельзя: у поиска по «Пилот» или + /// «Сезон 1» верхним окажется случайное чужое шоу, и в эфир уедет чужой постер с чужим рейтингом. + /// Промахи остаются админу — точечно, с глазами. + /// + private static MetadataCandidate? Pick(IReadOnlyList candidates, Show show) + { + var names = new[] { show.OriginalName, show.Name } + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Select(n => n!.Trim()) + .ToList(); + + var matches = candidates + .Where(c => + names.Any(n => string.Equals(c.Title.Trim(), n, StringComparison.OrdinalIgnoreCase)) + ) + .Where(c => show.Year is not { } year || c.Year is null || c.Year == year) + .ToList(); + + return matches.Count == 1 ? matches[0] : null; + } + + private static EnrichSkipDto Skip(Show show, string reason) => new(show.Id, show.Name, reason); +} diff --git a/backend/src/TeleWave.Application/Metadata/ShowMetadataApplier.cs b/backend/src/TeleWave.Application/Metadata/ShowMetadataApplier.cs new file mode 100644 index 0000000..aaa2443 --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/ShowMetadataApplier.cs @@ -0,0 +1,87 @@ +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Library.Genres; +using TeleWave.Domain.Images; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Metadata; + +/// +/// Применение найденных метаданных к шоу: описание, год, постер в реестр изображений, жанры и +/// возрастной рейтинг. Общее для точечного применения и массового обогащения — правила «что можно +/// перезаписать» должны быть одни, иначе пакетная обработка тихо разойдётся с ручной. +/// +public sealed class ShowMetadataApplier( + IAppDbContext dbContext, + IMetadataProviderResolver resolver, + IImageDownloader downloader, + IImageStore imageStore, + GenreMatcher genreMatcher +) +{ + public async Task ApplyAsync( + Show show, + string providerKey, + string externalId, + CancellationToken cancellationToken + ) + { + // Ролики метаданными не обогащаются: у них нет ни года, ни постера, а сотня роликов + // в поиске только мешала бы. + if (show.Kind == ShowKind.Interstitial) + return Result.Failure(MetadataErrors.NotForInterstitials); + + var provider = resolver.Resolve(providerKey); + if (provider is null) + return Result.Failure(MetadataErrors.ProviderNotAvailable); + + // Тип берём у шоу, а не из запроса: иначе клиент смог бы заставить TMDb отдать метаданные + // одноимённого фильма для сериала — идентификаторы у них независимые. + var meta = await provider.GetShowAsync(externalId, show.Kind, cancellationToken); + if (meta is null) + return Result.Failure(MetadataErrors.NotFound); + + var posterImageId = await SavePosterAsync(show, meta.PosterUrl, cancellationToken); + + show.ApplyMetadata(providerKey, meta.ExternalId, meta.Overview, meta.Year, posterImageId); + + // Жанры источника перезаписывают текущие, только если хоть один распознан справочником: + // иначе применение метаданных к шоу с руками проставленными жанрами обнуляло бы их. + var genreIds = await genreMatcher.MatchAsync(meta.Genres, cancellationToken); + if (genreIds.Count > 0) + show.SetGenres(genreIds); + + // Рейтинг — по тому же правилу: проставляем только распознанный. «Not Rated», «N/A» и местные + // шкалы означают отсутствие данных, и снимать ими уже проставленный рейтинг нельзя — иначе + // «обновить метаданные» тихо открыло бы шоу дорогу в детское время. + if (ContentRating.Parse(meta.ContentRating) is { } audience) + show.SetAudience(audience); + + return Result.Success(); + } + + /// Постер скачиваем и регистрируем в общем реестре изображений (галерея). + private async Task SavePosterAsync( + Show show, + string? posterUrl, + CancellationToken cancellationToken + ) + { + if (string.IsNullOrEmpty(posterUrl)) + return null; + + var downloaded = await downloader.DownloadAsync(posterUrl, cancellationToken); + if (downloaded is null) + return 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 + ); + return image.Id; + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupFilter.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupFilter.cs index 3ba8962..d2c4f8a 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/GroupFilter.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/GroupFilter.cs @@ -6,14 +6,17 @@ using TeleWave.Domain.Programming; namespace TeleWave.Application.Programming.Groups; /// -/// Правило быстрого набора состава группы. Это не запрос, выполняемый при генерации: фильтр находит -/// подходящие позиции, а добавляет их в группу отдельная команда. Пустые поля не ограничивают. +/// Правило набора состава группы. Пустые поля не ограничивают. +/// +/// В статической группе это помощник наполнения: правило находит подходящие позиции, а добавляет их +/// отдельная команда. В динамической — оно и есть состав, вычисляемый при каждом обращении. /// public sealed record GroupFilter( IReadOnlyList? ElementKinds = null, IReadOnlyList? ShowKinds = null, IReadOnlyList? GenreIds = null, ShowAudience? MaxAudience = null, + ShowAudience? MinAudience = null, int? YearMin = null, int? YearMax = null, int? UnitMinutesMin = null, diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupFilterMatcher.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupFilterMatcher.cs index e3206b9..046b528 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/GroupFilterMatcher.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/GroupFilterMatcher.cs @@ -71,6 +71,12 @@ public sealed class GroupFilterMatcher(IAppDbContext dbContext) if (filter.MaxAudience is { } maxAudience) shows = shows.Where(s => s.Audience == null || s.Audience <= maxAudience); + // Нижняя граница ведёт себя иначе, чем верхняя: непроставленный рейтинг из выборки уходит. + // «Не строже PG» с неизвестным рейтингом — допустимый риск, а «R и старше» с ним означало бы + // мультики в ночном слоте, то есть ровно ту ошибку, ради которой границу и заводят. + if (filter.MinAudience is { } minAudience) + shows = shows.Where(s => s.Audience != null && s.Audience >= minAudience); + if (filter.YearMin is { } yearMin) shows = shows.Where(s => s.Year != null && s.Year >= yearMin); if (filter.YearMax is { } yearMax) diff --git a/backend/src/TeleWave.Application/Programming/Groups/Suggest/GroupSuggestionBuilder.cs b/backend/src/TeleWave.Application/Programming/Groups/Suggest/GroupSuggestionBuilder.cs index a8af6ef..47dae54 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/Suggest/GroupSuggestionBuilder.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/Suggest/GroupSuggestionBuilder.cs @@ -48,6 +48,12 @@ public sealed class GroupSuggestionBuilder(IAppDbContext dbContext, GroupElement (ShowAudience.Pg13, "Семейное (не строже PG-13)"), ]; + /// Нижний порог — под ночные слоты. Непроставленный рейтинг сюда не попадает. + private static readonly (ShowAudience Min, string Name) AdultTier = ( + ShowAudience.R, + "Взрослое (R и старше)" + ); + private static readonly (ShowKind Kind, string Name)[] ShowKindNames = [ (ShowKind.Series, "Сериалы"), @@ -166,6 +172,17 @@ public sealed class GroupSuggestionBuilder(IAppDbContext dbContext, GroupElement stats ); } + + var adult = shows.Where(s => s.Audience >= AdultTier.Min).ToList(); + if (adult.Count >= MinShows) + yield return Build( + $"audience-min:{AdultTier.Min}", + GroupSuggestionKind.Audience, + AdultTier.Name, + new GroupFilter(MinAudience: AdultTier.Min), + adult, + stats + ); } /// diff --git a/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs index 64a9b63..8b93c03 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs @@ -3,13 +3,16 @@ using Microsoft.EntityFrameworkCore; using TeleWave.Application.Broadcast; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; +using TeleWave.Application.Programming.Groups; using TeleWave.Domain.Library; using TeleWave.Domain.Programming; namespace TeleWave.Application.Programming.Templates.Validate; -public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext) - : IQueryHandler>> +public sealed class ValidateTemplateQueryHandler( + IAppDbContext dbContext, + DynamicGroupResolver dynamicResolver +) : IQueryHandler>> { private const int MinutesInDay = 24 * 60; @@ -42,14 +45,26 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext) .Distinct() .ToList(); - var groups = await dbContext + // Состав считаем, а не читаем кэш ItemCount: у группы по правилу кэш отстаёт от библиотеки, + // и проверка «группа пуста» врала бы в обе стороны — и пугала бы зря, и пропускала бы + // действительно опустевшую группу. + var loaded = await dbContext .Groups.AsNoTracking() + .Include(g => g.Items) .Where(g => groupIds.Contains(g.Id)) - .Select(g => new GroupStats(g.Id, g.Name, g.ItemCount)) - .ToDictionaryAsync(g => g.Id, cancellationToken); + .ToListAsync(cancellationToken); + + var composition = new Dictionary>(); + foreach (var group in loaded) + composition[group.Id] = await dynamicResolver.ResolveAsync(group, cancellationToken); + + var groups = loaded.ToDictionary( + g => g.Id, + g => new GroupStats(g.Id, g.Name, composition[g.Id].Count) + ); var rules = PlanningRules.FromJson(template.RulesJson); - var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken); + var strictest = await LoadStrictestAudienceAsync(composition, cancellationToken); var dayStart = channel.DayStartTime; var issues = new List(); @@ -256,32 +271,32 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext) /// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не /// идут: планировщик их не отсекает, значит и предупреждать не о чем. /// + /// + /// Самый строгий рейтинг в каждой группе — по вычисленному составу, а не по строкам таблицы: + /// иначе у динамической группы в расчёт попало бы исключённое и не попало бы найденное правилом. + /// private async Task> LoadStrictestAudienceAsync( - IReadOnlyCollection groupIds, + IReadOnlyDictionary> composition, CancellationToken cancellationToken ) { - if (groupIds.Count == 0) + var items = composition + .SelectMany(pair => + pair.Value.Select(e => new + { + GroupId = pair.Key, + e.Kind, + e.Id, + }) + ) + .ToList(); + if (items.Count == 0) return []; - var items = await dbContext - .GroupItems.AsNoTracking() - .Where(i => groupIds.Contains(i.GroupId)) - .Select(i => new - { - i.GroupId, - i.ElementKind, - i.ElementId, - }) - .ToListAsync(cancellationToken); - - var showIds = items - .Where(i => i.ElementKind == GroupElementKind.Show) - .Select(i => i.ElementId) - .ToList(); + var showIds = items.Where(i => i.Kind == GroupElementKind.Show).Select(i => i.Id).ToList(); var collectionIds = items - .Where(i => i.ElementKind == GroupElementKind.Collection) - .Select(i => i.ElementId) + .Where(i => i.Kind == GroupElementKind.Collection) + .Select(i => i.Id) .ToList(); var partsByCollection = await dbContext @@ -307,10 +322,10 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext) foreach (var item in items) { var candidates = - item.ElementKind == GroupElementKind.Show - ? [item.ElementId] + item.Kind == GroupElementKind.Show + ? [item.Id] : partsByCollection - .Where(p => p.CollectionId == item.ElementId) + .Where(p => p.CollectionId == item.Id) .Select(p => p.ShowId) .ToList(); diff --git a/backend/tests/TeleWave.Application.Tests/Library/BulkTagShowsTests.cs b/backend/tests/TeleWave.Application.Tests/Library/BulkTagShowsTests.cs new file mode 100644 index 0000000..7803724 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Library/BulkTagShowsTests.cs @@ -0,0 +1,148 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Library.BulkTagShows; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Library; +using Xunit; + +namespace TeleWave.Application.Tests.Library; + +/// +/// Массовая разметка: жанры и рейтинг — то, на что опираются группы контента, и проставлять их +/// по одному шоу нереально. Проверяем, что пакет не затирает лишнего. +/// +public class BulkTagShowsTests +{ + [Fact] + public async Task AddsGenres_WithoutErasingExisting() + { + var fixture = new TestDb(); + var existing = Genre.Create("Комедия", "comedy"); + var added = Genre.Create("Мультфильм", "animation"); + var show = Show.Create("A", ShowKind.Series); + show.SetGenres([existing.Id]); + + await using (var seed = fixture.New()) + { + seed.Genres.AddRange(existing, added); + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await RunAsync(fixture, new BulkTagShowsCommand([show.Id], GenreIds: [added.Id])); + + await using var check = fixture.New(); + var stored = check.Shows.Include(s => s.Genres).Single(); + Assert.Equal(2, stored.Genres.Count); + // Основной жанр не должен незаметно переехать: его показывает список шоу. + Assert.Equal(existing.Id, stored.PrimaryGenreId); + } + + [Fact] + public async Task ReplacesGenres_WhenAsked() + { + var fixture = new TestDb(); + var old = Genre.Create("Комедия", "comedy"); + var fresh = Genre.Create("Мультфильм", "animation"); + var show = Show.Create("A", ShowKind.Series); + show.SetGenres([old.Id]); + + await using (var seed = fixture.New()) + { + seed.Genres.AddRange(old, fresh); + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await RunAsync( + fixture, + new BulkTagShowsCommand([show.Id], GenreIds: [fresh.Id], ReplaceGenres: true) + ); + + await using var check = fixture.New(); + var stored = check.Shows.Include(s => s.Genres).Single(); + Assert.Equal(fresh.Id, stored.Genres.Single().GenreId); + } + + [Fact] + public async Task SetsAudience_AndLeavesGenresAlone() + { + var fixture = new TestDb(); + var genre = Genre.Create("Комедия", "comedy"); + var show = Show.Create("A", ShowKind.Series); + show.SetGenres([genre.Id]); + + await using (var seed = fixture.New()) + { + seed.Genres.Add(genre); + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await RunAsync( + fixture, + new BulkTagShowsCommand([show.Id], Audience: ShowAudience.Pg13, SetAudience: true) + ); + + await using var check = fixture.New(); + var stored = check.Shows.Include(s => s.Genres).Single(); + Assert.Equal(ShowAudience.Pg13, stored.Audience); + Assert.Single(stored.Genres); + } + + [Fact] + public async Task FailsForUnknownGenre() + { + // Ссылка на несуществующий жанр упала бы нарушением внешнего ключа посреди пакета. + var fixture = new TestDb(); + var show = Show.Create("A", ShowKind.Series); + await using (var seed = fixture.New()) + { + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new BulkTagShowsCommandHandler(db).Handle( + new BulkTagShowsCommand([show.Id], GenreIds: [Guid.NewGuid()]), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + } + + [Fact] + public async Task SkipsMissingShows_WithoutFailing() + { + var fixture = new TestDb(); + var show = Show.Create("A", ShowKind.Series); + await using (var seed = fixture.New()) + { + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var updated = await RunAsync( + fixture, + new BulkTagShowsCommand( + [show.Id, Guid.NewGuid()], + Audience: ShowAudience.R, + SetAudience: true + ) + ); + + // Пока собирали галочки, шоу могли удалить — это не повод валить всю операцию. + Assert.Equal(1, updated); + } + + private static async Task RunAsync(TestDb fixture, BulkTagShowsCommand command) + { + await using var db = fixture.New(); + var result = await new BulkTagShowsCommandHandler(db).Handle( + command, + CancellationToken.None + ); + Assert.True(result.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + return result.Value; + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/ApplyShowMetadataTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/ApplyShowMetadataTests.cs index a50596a..5d9bb36 100644 --- a/backend/tests/TeleWave.Application.Tests/Metadata/ApplyShowMetadataTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Metadata/ApplyShowMetadataTests.cs @@ -77,10 +77,13 @@ public class ApplyShowMetadataTests await using var db = arranged.Fixture.New(); var handler = new ApplyShowMetadataCommandHandler( db, - arranged.Resolver, - Substitute.For(), - Substitute.For(), - new GenreMatcher(db) + new ShowMetadataApplier( + db, + arranged.Resolver, + Substitute.For(), + Substitute.For(), + new GenreMatcher(db) + ) ); var result = await handler.Handle( diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/EnrichShowsTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/EnrichShowsTests.cs new file mode 100644 index 0000000..09bd961 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Metadata/EnrichShowsTests.cs @@ -0,0 +1,137 @@ +using NSubstitute; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Library.Genres; +using TeleWave.Application.Metadata; +using TeleWave.Application.Metadata.EnrichShows; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Library; +using Xunit; + +namespace TeleWave.Application.Tests.Metadata; + +/// +/// Массовое обогащение метаданными. Главное свойство — осторожность: чужой постер и чужой рейтинг +/// у шоу в эфире хуже, чем непроставленные метаданные. +/// +public class EnrichShowsTests +{ + [Fact] + public async Task Applies_WhenTitleMatchesExactly() + { + var fixture = new TestDb(); + var show = Show.Create("Гравити Фолз", ShowKind.Series); + show.SetOriginalName("Gravity Falls"); + await SeedAsync(fixture, show); + + var provider = ProviderWith([ + new MetadataCandidate("42", "Gravity Falls", 2012, null, null, ShowKind.Series), + ]); + provider + .GetShowAsync("42", Arg.Any(), Arg.Any()) + .Returns(new ShowMetadata("42", "Gravity Falls", 2012, "desc", null, [], null)); + + var result = await RunAsync(fixture, provider, show.Id); + + Assert.Equal(1, result.Applied); + Assert.Empty(result.Skipped); + } + + [Fact] + public async Task Skips_WhenSeveralCandidatesShareTheTitle() + { + // Два одноимённых — угадывать нельзя: у них разные постеры и разные рейтинги. + var fixture = new TestDb(); + var show = Show.Create("Gravity Falls", ShowKind.Series); + await SeedAsync(fixture, show); + + var provider = ProviderWith([ + new MetadataCandidate("1", "Gravity Falls", null, null, null, ShowKind.Series), + new MetadataCandidate("2", "Gravity Falls", null, null, null, ShowKind.Series), + ]); + + var result = await RunAsync(fixture, provider, show.Id); + + Assert.Equal(0, result.Applied); + Assert.Single(result.Skipped); + } + + [Fact] + public async Task Skips_WhenOnlyLooseMatchFound() + { + var fixture = new TestDb(); + var show = Show.Create("Пилот", ShowKind.Series); + await SeedAsync(fixture, show); + + var provider = ProviderWith([ + new MetadataCandidate("7", "Совсем другое шоу", null, null, null, ShowKind.Series), + ]); + + var result = await RunAsync(fixture, provider, show.Id); + + Assert.Equal(0, result.Applied); + Assert.Single(result.Skipped); + } + + [Fact] + public async Task Skips_AlreadyLinked() + { + // Ручной выбор источника массовая операция переписывать не должна. + var fixture = new TestDb(); + var show = Show.Create("Gravity Falls", ShowKind.Series); + show.ApplyMetadata("tmdb", "999", null, null, null); + await SeedAsync(fixture, show); + + var provider = ProviderWith([ + new MetadataCandidate("42", "Gravity Falls", null, null, null, ShowKind.Series), + ]); + + var result = await RunAsync(fixture, provider, show.Id); + + Assert.Equal(0, result.Applied); + Assert.Single(result.Skipped); + } + + private static async Task SeedAsync(TestDb fixture, Show show) + { + await using var seed = fixture.New(); + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + private static IMetadataProvider ProviderWith(IReadOnlyList candidates) + { + var provider = Substitute.For(); + provider + .SearchShowsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(candidates); + return provider; + } + + private static async Task RunAsync( + TestDb fixture, + IMetadataProvider provider, + Guid showId + ) + { + var resolver = Substitute.For(); + resolver.Resolve("tmdb").Returns(provider); + + await using var db = fixture.New(); + var applier = new ShowMetadataApplier( + db, + resolver, + Substitute.For(), + Substitute.For(), + new GenreMatcher(db) + ); + + var result = await new EnrichShowsMetadataCommandHandler(db, resolver, applier).Handle( + new EnrichShowsMetadataCommand([showId], "tmdb"), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + return result.Value; + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/ValidateTemplateTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/ValidateTemplateTests.cs index 0e18dfd..18c5be2 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/ValidateTemplateTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/ValidateTemplateTests.cs @@ -55,13 +55,20 @@ public class ValidateTemplateTests ) ); + /// Наполняет группу позициями: проверке важно их число, а не что за ними стоит. + private static void FillGroup(Group group, int count) + { + for (var i = 0; i < count; i++) + group.AddElement(GroupElementKind.Show, Guid.NewGuid()); + } + private static async Task> ValidateAsync( TestDb fixture, Guid channelId ) { await using var db = fixture.New(); - var result = await new ValidateTemplateQueryHandler(db).Handle( + var result = await new ValidateTemplateQueryHandler(db, GroupServices.Dynamic(db)).Handle( new ValidateTemplateQuery(channelId), CancellationToken.None ); @@ -75,7 +82,7 @@ public class ValidateTemplateTests var fixture = new TestDb(); await using var db = fixture.New(); - var result = await new ValidateTemplateQueryHandler(db).Handle( + var result = await new ValidateTemplateQueryHandler(db, GroupServices.Dynamic(db)).Handle( new ValidateTemplateQuery(Guid.NewGuid()), CancellationToken.None ); @@ -95,7 +102,7 @@ public class ValidateTemplateTests } await using var db = fixture.New(); - var result = await new ValidateTemplateQueryHandler(db).Handle( + var result = await new ValidateTemplateQueryHandler(db, GroupServices.Dynamic(db)).Handle( new ValidateTemplateQuery(channel.Id), CancellationToken.None ); @@ -177,7 +184,9 @@ public class ValidateTemplateTests var fixture = new TestDb(); var channel = Channel.Create("Первый", "one", T0); var group = Group.Create("Маленькая"); - group.UpdateStats(3, 3, TimeSpan.FromHours(3), T0); + // Позиции настоящие: проверка считает состав, а не кэш статистики, — у динамической группы + // кэш отстаёт от библиотеки, и полагаться на него нельзя. + FillGroup(group, 3); var template = ScheduleTemplate.Create(channel.Id, "Сетка"); var slot = FullDaySlot(template.Background!, group.Id, channel.DayStartTime); @@ -212,7 +221,7 @@ public class ValidateTemplateTests var fixture = new TestDb(); var channel = Channel.Create("Первый", "one", T0); var group = Group.Create("Кино"); - group.UpdateStats(50, 50, TimeSpan.FromHours(50), T0); + FillGroup(group, 50); var template = ScheduleTemplate.Create(channel.Id, "Сетка"); FullDaySlot(template.Background!, group.Id, channel.DayStartTime); @@ -255,7 +264,7 @@ public class ValidateTemplateTests var group = Group.Create("Вечернее"); group.AddElement(GroupElementKind.Show, mild.Id); group.AddElement(GroupElementKind.Collection, collection.Id); - group.UpdateStats(2, 2, TimeSpan.FromHours(2), T0); + FillGroup(group, 2); var template = ScheduleTemplate.Create(channel.Id, "Сетка"); var slot = FullDaySlot(template.Background!, group.Id, channel.DayStartTime); diff --git a/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs index db3d1a4..426e7a5 100644 --- a/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs +++ b/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Programming.Groups; using TeleWave.Application.Programming.Templates; using TeleWave.Application.Programming.Templates.CopyTemplate; using TeleWave.Application.Programming.Templates.CreateTemplate; @@ -81,10 +82,10 @@ public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture) var (sourceId, _, _) = await SeedPairAsync(seedDb); await using var db = fixture.CreateContext(); - var result = await new ValidateTemplateQueryHandler(db).Handle( - new ValidateTemplateQuery(sourceId), - default - ); + var result = await new ValidateTemplateQueryHandler( + db, + new DynamicGroupResolver(new GroupFilterMatcher(db), new GroupElementResolver(db)) + ).Handle(new ValidateTemplateQuery(sourceId), default); Assert.True(result.IsSuccess); // Группа в сиде пустая, а слот занимает лишь два часа суток — обе проверки должны сработать. diff --git a/frontend/src/features/admin/groups/GroupFilterPanel.tsx b/frontend/src/features/admin/groups/GroupFilterPanel.tsx index dccc6e0..be8c66a 100644 --- a/frontend/src/features/admin/groups/GroupFilterPanel.tsx +++ b/frontend/src/features/admin/groups/GroupFilterPanel.tsx @@ -101,6 +101,26 @@ export function GroupFilterPanel({

{t('admin.groups.filter.audienceHint')}

+ {/* Нижняя граница — под ночные слоты. Ведёт себя не зеркально верхней, поэтому и подсказка своя. */} +
+ + +

{t('admin.groups.filter.minAudienceHint')}

+
+
diff --git a/frontend/src/features/admin/shows/BulkTagBar.tsx b/frontend/src/features/admin/shows/BulkTagBar.tsx new file mode 100644 index 0000000..2e5bcfa --- /dev/null +++ b/frontend/src/features/admin/shows/BulkTagBar.tsx @@ -0,0 +1,137 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Sparkles, Tags } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { listGenres } from '@/features/admin/genres/api' +import { qk } from '@/shared/api/query-keys' +import { AUDIENCE_UNSET, SHOW_AUDIENCES, type ShowAudience } from '@/shared/api/types' +import { useApiError } from '@/shared/lib/use-api-error' +import { Button } from '@/shared/ui/button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import { toast } from '@/shared/ui/toast-store' +import { bulkEnrichShows, bulkTagShows, getMetadataProviders } from './api' + +/** Значение «не трогать рейтинг» в селекте — отдельное от «снять рейтинг» (AUDIENCE_UNSET). */ +const KEEP = 'keep' + +/** + * Массовые действия над отмеченными шоу. Появляется, только когда что-то отмечено, — в обычном + * просмотре списка не мешает. + * + * Жанры дописываются, а не заменяют: у шоу уже есть жанры из метаданных, и разметка «это ещё и + * детское» не должна их стирать. Замену при необходимости делают на карточке шоу. + */ +export function BulkTagBar({ + showIds, + onDone, +}: Readonly<{ showIds: string[]; onDone: () => void }>) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const onError = useApiError() + const [genreId, setGenreId] = useState('') + const [audience, setAudience] = useState(KEEP) + + const { data: genres } = useQuery({ queryKey: qk.genres.all, queryFn: listGenres }) + const { data: providers } = useQuery({ + queryKey: qk.metadata.providers, + queryFn: getMetadataProviders, + }) + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: qk.shows.all }) + onDone() + } + + const tag = useMutation({ + mutationFn: () => + bulkTagShows({ + showIds, + genreIds: genreId ? [genreId] : undefined, + audience: audience === AUDIENCE_UNSET ? null : (audience as ShowAudience), + setAudience: audience !== KEEP, + }), + onSuccess: (result) => { + toast.success(t('admin.shows.bulk.tagged', { count: result.updated })) + setGenreId('') + setAudience(KEEP) + invalidate() + }, + onError, + }) + + const enrich = useMutation({ + mutationFn: () => bulkEnrichShows(showIds, providers?.[0] ?? ''), + onSuccess: (result) => { + toast.success(t('admin.shows.bulk.enriched', { count: result.applied })) + // Пропущенное показываем по одному: у каждого своя причина, и это подсказка, что делать руками. + for (const skip of result.skipped.slice(0, 5)) toast.error(`${skip.showName}: ${skip.reason}`) + if (result.skipped.length > 5) + toast.error(t('admin.shows.bulk.moreSkipped', { count: result.skipped.length - 5 })) + invalidate() + }, + onError, + }) + + const nothingToApply = !genreId && audience === KEEP + + return ( +
+ + {t('admin.shows.bulk.selected', { count: showIds.length })} + + + + + + + + + {providers && providers.length > 0 && ( + + )} + + +
+ ) +} diff --git a/frontend/src/features/admin/shows/ShowsPanel.tsx b/frontend/src/features/admin/shows/ShowsPanel.tsx index 4767cc5..0325384 100644 --- a/frontend/src/features/admin/shows/ShowsPanel.tsx +++ b/frontend/src/features/admin/shows/ShowsPanel.tsx @@ -19,6 +19,7 @@ import { sortRows, useTableSort } from '@/shared/lib/table-sort' import { SortHeader } from '@/shared/ui/sortable' import { listGenres } from '@/features/admin/genres/api' import { createShow, deleteShow, listShows } from './api' +import { BulkTagBar } from './BulkTagBar' const PAGE_SIZE = 20 @@ -40,6 +41,8 @@ export function ShowsPanel() { // Фильтр по жанру — серверный: в списке видно только основной жанр, а отбирать нужно и по остальным. const [genreFilter, setGenreFilter] = useState('all') + // Отмеченные для массовых действий. Живут отдельно от фильтров: сузил список — выбор не потерялся. + const [selected, setSelected] = useState([]) const { data: genres } = useQuery({ queryKey: qk.genres.all, queryFn: listGenres }) const { data, isLoading } = useQuery({ queryKey: qk.shows.byGenre(genreFilter), @@ -171,10 +174,26 @@ export function ShowsPanel() {
+ {selected.length > 0 && setSelected([])} />} +
+ {isLoading && ( - )} {pageItems.map((show) => ( +
+ 0 && pageItems.every((s) => selected.includes(s.id))} + onChange={(e) => + setSelected((current) => + e.target.checked + ? [...new Set([...current, ...pageItems.map((s) => s.id)])] + : current.filter((id) => !pageItems.some((s) => s.id === id)), + ) + } + /> +
+ {t('common.loading')}
+ + setSelected((current) => + current.includes(show.id) + ? current.filter((id) => id !== show.id) + : [...current, show.id], + ) + } + /> + ('/admin/shows/bulk/tag', { method: 'POST', body }) +} + +/** Массовое обогащение: найти метаданные по названию и применить тем, у кого нет привязки. */ +export function bulkEnrichShows(showIds: string[], provider: string) { + return apiRequest('/admin/shows/bulk/enrich', { + method: 'POST', + body: { showIds, provider }, + }) +} + export function deleteShow(id: string) { return apiRequest(`/admin/shows/${id}`, { method: 'DELETE' }) } diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index a71f5ea..3f05509 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -234,6 +234,7 @@ export type GroupFilter = { showKinds?: ShowKind[] | null genreIds?: string[] | null maxAudience?: ShowAudience | null + minAudience?: ShowAudience | null yearMin?: number | null yearMax?: number | null unitMinutesMin?: number | null @@ -253,6 +254,12 @@ export type GroupSummaryDto = { createdAt: string } +/** Итог массового обогащения метаданными: что применилось и что пропущено с причиной. */ +export type EnrichShowsResult = { + applied: number + skipped: { showId: string; showName: string; reason: string }[] +} + /** Откуда группа берёт состав: явный список либо вычисление правилом. */ export type GroupMode = 'Static' | 'Dynamic' diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index ad898f8..c03f2c1 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -134,12 +134,15 @@ export const en = { }, filter: { title: 'Selection rule', - hint: 'The rule only finds candidates — the group composition stays an explicit list.', + hint: 'In an explicit list the rule only finds candidates; in a rule-based group it is the composition.', elementKinds: 'What to search', showKinds: 'Show type', genres: 'Genres', genresHint: 'Any of the checked ones.', maxAudience: 'No stricter than', + minAudience: 'No softer than', + minAudienceHint: + 'For late-night slots. Unlike the upper bound, unrated shows are excluded — otherwise unrated cartoons would slip into the adult group.', audienceHint: 'Ratings are ordered by strictness: G → PG → PG-13 → R → NC-17. Unrated shows stay in the selection.', year: 'Year', @@ -346,6 +349,21 @@ export const en = { genre: 'Genre', allGenres: 'All genres', otherGenres: 'Other genres', + bulk: { + selected: 'Selected: {{count}}', + selectPage: 'Select all on this page', + noGenre: 'Keep genres', + keepAudience: 'Keep rating', + apply: 'Apply', + tagged: 'Shows updated: {{count}}', + clear: 'Clear selection', + enrich: 'Metadata from {{provider}}', + enrichHint: + 'Search by name and apply — only for shows with no source linked. Ambiguous matches are skipped.', + enriching: 'Searching…', + enriched: 'Metadata applied: {{count}}', + moreSkipped: 'Skipped as well: {{count}}', + }, genresEmpty: 'No genres set', genresEdit: 'Genres', genresHint: diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 30c4129..3ef5df1 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -134,12 +134,15 @@ export const ru = { }, filter: { title: 'Правило набора', - hint: 'Правило только ищет кандидатов — состав группы остаётся явным списком.', + hint: 'В явном списке правило только ищет кандидатов; в группе «по правилу» оно и есть состав.', elementKinds: 'Что искать', showKinds: 'Тип шоу', genres: 'Жанры', genresHint: 'Любой из отмеченных.', maxAudience: 'Возраст не строже', + minAudience: 'Возраст не мягче', + minAudienceHint: + 'Для ночных слотов. В отличие от верхней границы, шоу без рейтинга сюда не попадают — иначе в «взрослое» просочились бы непроставленные мультики.', audienceHint: 'Рейтинги упорядочены по строгости: G → PG → PG-13 → R → NC-17. Шоу без рейтинга остаются в выборке.', year: 'Год', @@ -347,6 +350,21 @@ export const ru = { genre: 'Жанр', allGenres: 'Все жанры', otherGenres: 'Остальные жанры', + bulk: { + selected: 'Отмечено: {{count}}', + selectPage: 'Отметить все на странице', + noGenre: 'Жанр не менять', + keepAudience: 'Рейтинг не менять', + apply: 'Применить', + tagged: 'Обновлено шоу: {{count}}', + clear: 'Снять выбор', + enrich: 'Метаданные из {{provider}}', + enrichHint: + 'Найти по названию и применить — только тем, у кого источник ещё не привязан. Неоднозначные совпадения пропускаются.', + enriching: 'Ищем…', + enriched: 'Метаданные применены: {{count}}', + moreSkipped: 'Ещё пропущено: {{count}}', + }, genresEmpty: 'Жанры не проставлены', genresEdit: 'Жанры', genresHint: