Implement bulk tagging and enriching of shows with new API endpoints and frontend integration
Added new API endpoints for bulk tagging and enriching shows, allowing for mass updates of genres and audience ratings. Implemented backend logic to handle bulk operations and updated the Dependency Injection configuration to include necessary services. Enhanced the frontend with new components for selecting shows and applying bulk actions, improving the user experience for managing multiple shows simultaneously. Localization updates were made to support these new features in both English and Russian.
This commit is contained in:
@@ -2,6 +2,7 @@ using LiteCqrs;
|
|||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Library;
|
using TeleWave.Application.Library;
|
||||||
using TeleWave.Application.Library.AddEpisode;
|
using TeleWave.Application.Library.AddEpisode;
|
||||||
|
using TeleWave.Application.Library.BulkTagShows;
|
||||||
using TeleWave.Application.Library.CreateShow;
|
using TeleWave.Application.Library.CreateShow;
|
||||||
using TeleWave.Application.Library.DeleteShow;
|
using TeleWave.Application.Library.DeleteShow;
|
||||||
using TeleWave.Application.Library.GetShow;
|
using TeleWave.Application.Library.GetShow;
|
||||||
@@ -11,6 +12,7 @@ using TeleWave.Application.Library.RenameShow;
|
|||||||
using TeleWave.Application.Library.SetShowAudience;
|
using TeleWave.Application.Library.SetShowAudience;
|
||||||
using TeleWave.Application.Library.SetShowGenres;
|
using TeleWave.Application.Library.SetShowGenres;
|
||||||
using TeleWave.Application.Library.SetShowOriginalName;
|
using TeleWave.Application.Library.SetShowOriginalName;
|
||||||
|
using TeleWave.Application.Metadata.EnrichShows;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Infrastructure.Identity;
|
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}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
|
admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
|
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
// Массовые операции над отмеченными шоу: разметка руками и обогащение из источника.
|
||||||
|
admin.MapPost("/bulk/tag", BulkTag).Produces<BulkTaggedResponse>();
|
||||||
|
admin.MapPost("/bulk/enrich", BulkEnrich).Produces<EnrichShowsResultDto>();
|
||||||
admin
|
admin
|
||||||
.MapPost("/{id:guid}/episodes", AddEpisode)
|
.MapPost("/{id:guid}/episodes", AddEpisode)
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
@@ -172,10 +178,58 @@ public static class ShowEndpoints
|
|||||||
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
|
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> 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<IResult> 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 AddEpisodeBody(Guid MediaAssetId);
|
||||||
|
|
||||||
|
/// <summary>Массовая разметка: жанры и рейтинг применяются независимо друг от друга.</summary>
|
||||||
|
public sealed record BulkTagShowsBody(
|
||||||
|
IReadOnlyList<Guid> ShowIds,
|
||||||
|
IReadOnlyList<Guid>? GenreIds = null,
|
||||||
|
bool ReplaceGenres = false,
|
||||||
|
ShowAudience? Audience = null,
|
||||||
|
bool SetAudience = false
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record BulkEnrichBody(IReadOnlyList<Guid> ShowIds, string Provider);
|
||||||
|
|
||||||
|
/// <summary>Скольких шоу коснулась массовая правка.</summary>
|
||||||
|
public sealed record BulkTaggedResponse(int Updated);
|
||||||
|
|
||||||
public sealed record RenameShowBody(string Name);
|
public sealed record RenameShowBody(string Name);
|
||||||
|
|
||||||
public sealed record SetShowOriginalNameBody(string? OriginalName);
|
public sealed record SetShowOriginalNameBody(string? OriginalName);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using TeleWave.Application.Broadcast.Bumpers;
|
using TeleWave.Application.Broadcast.Bumpers;
|
||||||
using TeleWave.Application.Common.Behaviors;
|
using TeleWave.Application.Common.Behaviors;
|
||||||
using TeleWave.Application.Library.Genres;
|
using TeleWave.Application.Library.Genres;
|
||||||
|
using TeleWave.Application.Metadata;
|
||||||
using TeleWave.Application.Programming.Groups;
|
using TeleWave.Application.Programming.Groups;
|
||||||
using TeleWave.Application.Programming.Groups.Suggest;
|
using TeleWave.Application.Programming.Groups.Suggest;
|
||||||
using TeleWave.Application.Programming.Planning;
|
using TeleWave.Application.Programming.Planning;
|
||||||
@@ -36,6 +37,7 @@ public static class DependencyInjection
|
|||||||
// здесь, а не в Infrastructure: тот слой не должен знать внутреннего устройства Application.
|
// здесь, а не в Infrastructure: тот слой не должен знать внутреннего устройства Application.
|
||||||
services.AddScoped<BumperSpecLoader>();
|
services.AddScoped<BumperSpecLoader>();
|
||||||
services.AddScoped<GenreMatcher>();
|
services.AddScoped<GenreMatcher>();
|
||||||
|
services.AddScoped<ShowMetadataApplier>();
|
||||||
services.AddScoped<GroupElementResolver>();
|
services.AddScoped<GroupElementResolver>();
|
||||||
services.AddScoped<GroupFilterMatcher>();
|
services.AddScoped<GroupFilterMatcher>();
|
||||||
services.AddScoped<DynamicGroupResolver>();
|
services.AddScoped<DynamicGroupResolver>();
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.BulkTagShows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Проставляет жанры и/или возрастной рейтинг сразу нескольким шоу. Оба поля необязательны и
|
||||||
|
/// применяются независимо: обычно правят что-то одно, а связывать их в одну форму значило бы
|
||||||
|
/// заставлять переставлять второе «как было».
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="GenreIds">
|
||||||
|
/// Что делать с жанрами: null — не трогать. Иначе <paramref name="ReplaceGenres"/> решает, заменить
|
||||||
|
/// набор целиком или дописать к имеющимся.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ReplaceGenres">
|
||||||
|
/// true — заменить набор жанров переданным; false — дописать. Дописывание нужно чаще: у шоу уже
|
||||||
|
/// есть жанры из метаданных, и разметка «это ещё и детское» не должна их стирать.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Audience">Рейтинг: null со <paramref name="SetAudience"/> = true снимает проставленный.</param>
|
||||||
|
/// <param name="SetAudience">Трогать ли рейтинг вообще.</param>
|
||||||
|
public sealed record BulkTagShowsCommand(
|
||||||
|
IReadOnlyList<Guid> ShowIds,
|
||||||
|
IReadOnlyList<Guid>? GenreIds = null,
|
||||||
|
bool ReplaceGenres = false,
|
||||||
|
ShowAudience? Audience = null,
|
||||||
|
bool SetAudience = false
|
||||||
|
) : ICommand<Result<int>>;
|
||||||
@@ -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<BulkTagShowsCommand, Result<int>>
|
||||||
|
{
|
||||||
|
public async Task<Result<int>> 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<int>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Основным остаётся прежний основной жанр — массовая разметка не должна незаметно менять то,
|
||||||
|
/// что показано в списке шоу. Если его не было (жанров не было вовсе), основным станет первый
|
||||||
|
/// из добавленных, как и при обычной правке.
|
||||||
|
/// </summary>
|
||||||
|
private static void ApplyGenres(
|
||||||
|
Domain.Library.Show show,
|
||||||
|
IReadOnlyList<Guid> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.BulkTagShows;
|
||||||
|
|
||||||
|
public sealed class BulkTagShowsCommandValidator : AbstractValidator<BulkTagShowsCommand>
|
||||||
|
{
|
||||||
|
public BulkTagShowsCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.ShowIds).NotEmpty();
|
||||||
|
// Команда, которая ничего не меняет, — почти наверняка недосмотр вызывающей стороны.
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => x.GenreIds is not null || x.SetAudience)
|
||||||
|
.WithMessage("Нечего применять: не заданы ни жанры, ни рейтинг.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-60
@@ -3,18 +3,12 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Application.Library;
|
using TeleWave.Application.Library;
|
||||||
using TeleWave.Application.Library.Genres;
|
|
||||||
using TeleWave.Domain.Images;
|
|
||||||
using TeleWave.Domain.Library;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Metadata.ApplyShowMetadata;
|
namespace TeleWave.Application.Metadata.ApplyShowMetadata;
|
||||||
|
|
||||||
public sealed class ApplyShowMetadataCommandHandler(
|
public sealed class ApplyShowMetadataCommandHandler(
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
IMetadataProviderResolver resolver,
|
ShowMetadataApplier applier
|
||||||
IImageDownloader downloader,
|
|
||||||
IImageStore imageStore,
|
|
||||||
GenreMatcher genreMatcher
|
|
||||||
) : ICommandHandler<ApplyShowMetadataCommand, Result>
|
) : ICommandHandler<ApplyShowMetadataCommand, Result>
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(
|
public async Task<Result> Handle(
|
||||||
@@ -28,60 +22,11 @@ public sealed class ApplyShowMetadataCommandHandler(
|
|||||||
if (show is null)
|
if (show is null)
|
||||||
return Result.Failure(ShowErrors.NotFound);
|
return Result.Failure(ShowErrors.NotFound);
|
||||||
|
|
||||||
// Ролики метаданными не обогащаются: у них нет ни года, ни постера, а сотня роликов
|
return await applier.ApplyAsync(
|
||||||
// в поиске только мешала бы. Раньше это держалось на том, что UI такой кнопки не рисует.
|
show,
|
||||||
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(
|
|
||||||
command.Provider,
|
command.Provider,
|
||||||
meta.ExternalId,
|
command.ExternalId,
|
||||||
meta.Overview,
|
cancellationToken
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Metadata.EnrichShows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Массовое обогащение: найти метаданные по названию и применить — для шоу, у которых источник ещё
|
||||||
|
/// не привязан. Список шоу приходит явно (отмеченные галочками), а не «все подряд»: автоподбор по
|
||||||
|
/// названию иногда промахивается, и цена ошибки — чужой постер и чужой рейтинг у шоу в эфире.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record EnrichShowsMetadataCommand(IReadOnlyList<Guid> ShowIds, string Provider)
|
||||||
|
: ICommand<Result<EnrichShowsResultDto>>;
|
||||||
|
|
||||||
|
/// <param name="Applied">Скольким шоу метаданные применены.</param>
|
||||||
|
/// <param name="Skipped">Что пропущено и почему — по одной строке на шоу.</param>
|
||||||
|
public sealed record EnrichShowsResultDto(int Applied, IReadOnlyList<EnrichSkipDto> Skipped);
|
||||||
|
|
||||||
|
public sealed record EnrichSkipDto(Guid ShowId, string ShowName, string Reason);
|
||||||
+97
@@ -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<EnrichShowsMetadataCommand, Result<EnrichShowsResultDto>>
|
||||||
|
{
|
||||||
|
public async Task<Result<EnrichShowsResultDto>> Handle(
|
||||||
|
EnrichShowsMetadataCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var provider = resolver.Resolve(command.Provider);
|
||||||
|
if (provider is null)
|
||||||
|
return Result.Failure<EnrichShowsResultDto>(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<EnrichSkipDto>();
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Кандидат берётся, только если название совпало точно (без учёта регистра и пробелов), а при
|
||||||
|
/// известном годе сходится и он. Брать «первый из выдачи» нельзя: у поиска по «Пилот» или
|
||||||
|
/// «Сезон 1» верхним окажется случайное чужое шоу, и в эфир уедет чужой постер с чужим рейтингом.
|
||||||
|
/// Промахи остаются админу — точечно, с глазами.
|
||||||
|
/// </summary>
|
||||||
|
private static MetadataCandidate? Pick(IReadOnlyList<MetadataCandidate> 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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Применение найденных метаданных к шоу: описание, год, постер в реестр изображений, жанры и
|
||||||
|
/// возрастной рейтинг. Общее для точечного применения и массового обогащения — правила «что можно
|
||||||
|
/// перезаписать» должны быть одни, иначе пакетная обработка тихо разойдётся с ручной.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ShowMetadataApplier(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IMetadataProviderResolver resolver,
|
||||||
|
IImageDownloader downloader,
|
||||||
|
IImageStore imageStore,
|
||||||
|
GenreMatcher genreMatcher
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public async Task<Result> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Постер скачиваем и регистрируем в общем реестре изображений (галерея).</summary>
|
||||||
|
private async Task<Guid?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,14 +6,17 @@ using TeleWave.Domain.Programming;
|
|||||||
namespace TeleWave.Application.Programming.Groups;
|
namespace TeleWave.Application.Programming.Groups;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Правило быстрого набора состава группы. Это не запрос, выполняемый при генерации: фильтр находит
|
/// Правило набора состава группы. Пустые поля не ограничивают.
|
||||||
/// подходящие позиции, а добавляет их в группу отдельная команда. Пустые поля не ограничивают.
|
///
|
||||||
|
/// В статической группе это помощник наполнения: правило находит подходящие позиции, а добавляет их
|
||||||
|
/// отдельная команда. В динамической — оно и есть состав, вычисляемый при каждом обращении.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record GroupFilter(
|
public sealed record GroupFilter(
|
||||||
IReadOnlyList<GroupElementKind>? ElementKinds = null,
|
IReadOnlyList<GroupElementKind>? ElementKinds = null,
|
||||||
IReadOnlyList<ShowKind>? ShowKinds = null,
|
IReadOnlyList<ShowKind>? ShowKinds = null,
|
||||||
IReadOnlyList<Guid>? GenreIds = null,
|
IReadOnlyList<Guid>? GenreIds = null,
|
||||||
ShowAudience? MaxAudience = null,
|
ShowAudience? MaxAudience = null,
|
||||||
|
ShowAudience? MinAudience = null,
|
||||||
int? YearMin = null,
|
int? YearMin = null,
|
||||||
int? YearMax = null,
|
int? YearMax = null,
|
||||||
int? UnitMinutesMin = null,
|
int? UnitMinutesMin = null,
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ public sealed class GroupFilterMatcher(IAppDbContext dbContext)
|
|||||||
if (filter.MaxAudience is { } maxAudience)
|
if (filter.MaxAudience is { } maxAudience)
|
||||||
shows = shows.Where(s => s.Audience == null || s.Audience <= 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)
|
if (filter.YearMin is { } yearMin)
|
||||||
shows = shows.Where(s => s.Year != null && s.Year >= yearMin);
|
shows = shows.Where(s => s.Year != null && s.Year >= yearMin);
|
||||||
if (filter.YearMax is { } yearMax)
|
if (filter.YearMax is { } yearMax)
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ public sealed class GroupSuggestionBuilder(IAppDbContext dbContext, GroupElement
|
|||||||
(ShowAudience.Pg13, "Семейное (не строже PG-13)"),
|
(ShowAudience.Pg13, "Семейное (не строже PG-13)"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// <summary>Нижний порог — под ночные слоты. Непроставленный рейтинг сюда не попадает.</summary>
|
||||||
|
private static readonly (ShowAudience Min, string Name) AdultTier = (
|
||||||
|
ShowAudience.R,
|
||||||
|
"Взрослое (R и старше)"
|
||||||
|
);
|
||||||
|
|
||||||
private static readonly (ShowKind Kind, string Name)[] ShowKindNames =
|
private static readonly (ShowKind Kind, string Name)[] ShowKindNames =
|
||||||
[
|
[
|
||||||
(ShowKind.Series, "Сериалы"),
|
(ShowKind.Series, "Сериалы"),
|
||||||
@@ -166,6 +172,17 @@ public sealed class GroupSuggestionBuilder(IAppDbContext dbContext, GroupElement
|
|||||||
stats
|
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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
+43
-28
@@ -3,13 +3,16 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using TeleWave.Application.Broadcast;
|
using TeleWave.Application.Broadcast;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Application.Programming.Groups;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
|
|
||||||
namespace TeleWave.Application.Programming.Templates.Validate;
|
namespace TeleWave.Application.Programming.Templates.Validate;
|
||||||
|
|
||||||
public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
|
public sealed class ValidateTemplateQueryHandler(
|
||||||
: IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
|
IAppDbContext dbContext,
|
||||||
|
DynamicGroupResolver dynamicResolver
|
||||||
|
) : IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
|
||||||
{
|
{
|
||||||
private const int MinutesInDay = 24 * 60;
|
private const int MinutesInDay = 24 * 60;
|
||||||
|
|
||||||
@@ -42,14 +45,26 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
|
|||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var groups = await dbContext
|
// Состав считаем, а не читаем кэш ItemCount: у группы по правилу кэш отстаёт от библиотеки,
|
||||||
|
// и проверка «группа пуста» врала бы в обе стороны — и пугала бы зря, и пропускала бы
|
||||||
|
// действительно опустевшую группу.
|
||||||
|
var loaded = await dbContext
|
||||||
.Groups.AsNoTracking()
|
.Groups.AsNoTracking()
|
||||||
|
.Include(g => g.Items)
|
||||||
.Where(g => groupIds.Contains(g.Id))
|
.Where(g => groupIds.Contains(g.Id))
|
||||||
.Select(g => new GroupStats(g.Id, g.Name, g.ItemCount))
|
.ToListAsync(cancellationToken);
|
||||||
.ToDictionaryAsync(g => g.Id, cancellationToken);
|
|
||||||
|
var composition = new Dictionary<Guid, IReadOnlyList<ResolvedElement>>();
|
||||||
|
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 rules = PlanningRules.FromJson(template.RulesJson);
|
||||||
var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken);
|
var strictest = await LoadStrictestAudienceAsync(composition, cancellationToken);
|
||||||
var dayStart = channel.DayStartTime;
|
var dayStart = channel.DayStartTime;
|
||||||
|
|
||||||
var issues = new List<TemplateIssueDto>();
|
var issues = new List<TemplateIssueDto>();
|
||||||
@@ -256,32 +271,32 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
|
|||||||
/// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не
|
/// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не
|
||||||
/// идут: планировщик их не отсекает, значит и предупреждать не о чем.
|
/// идут: планировщик их не отсекает, значит и предупреждать не о чем.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Самый строгий рейтинг в каждой группе — по вычисленному составу, а не по строкам таблицы:
|
||||||
|
/// иначе у динамической группы в расчёт попало бы исключённое и не попало бы найденное правилом.
|
||||||
|
/// </summary>
|
||||||
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
|
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
|
||||||
IReadOnlyCollection<Guid> groupIds,
|
IReadOnlyDictionary<Guid, IReadOnlyList<ResolvedElement>> composition,
|
||||||
CancellationToken cancellationToken
|
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 [];
|
return [];
|
||||||
|
|
||||||
var items = await dbContext
|
var showIds = items.Where(i => i.Kind == GroupElementKind.Show).Select(i => i.Id).ToList();
|
||||||
.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 collectionIds = items
|
var collectionIds = items
|
||||||
.Where(i => i.ElementKind == GroupElementKind.Collection)
|
.Where(i => i.Kind == GroupElementKind.Collection)
|
||||||
.Select(i => i.ElementId)
|
.Select(i => i.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var partsByCollection = await dbContext
|
var partsByCollection = await dbContext
|
||||||
@@ -307,10 +322,10 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
|
|||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
{
|
{
|
||||||
var candidates =
|
var candidates =
|
||||||
item.ElementKind == GroupElementKind.Show
|
item.Kind == GroupElementKind.Show
|
||||||
? [item.ElementId]
|
? [item.Id]
|
||||||
: partsByCollection
|
: partsByCollection
|
||||||
.Where(p => p.CollectionId == item.ElementId)
|
.Where(p => p.CollectionId == item.Id)
|
||||||
.Select(p => p.ShowId)
|
.Select(p => p.ShowId)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Массовая разметка: жанры и рейтинг — то, на что опираются группы контента, и проставлять их
|
||||||
|
/// по одному шоу нереально. Проверяем, что пакет не затирает лишнего.
|
||||||
|
/// </summary>
|
||||||
|
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<int> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,10 +77,13 @@ public class ApplyShowMetadataTests
|
|||||||
await using var db = arranged.Fixture.New();
|
await using var db = arranged.Fixture.New();
|
||||||
var handler = new ApplyShowMetadataCommandHandler(
|
var handler = new ApplyShowMetadataCommandHandler(
|
||||||
db,
|
db,
|
||||||
arranged.Resolver,
|
new ShowMetadataApplier(
|
||||||
Substitute.For<IImageDownloader>(),
|
db,
|
||||||
Substitute.For<IImageStore>(),
|
arranged.Resolver,
|
||||||
new GenreMatcher(db)
|
Substitute.For<IImageDownloader>(),
|
||||||
|
Substitute.For<IImageStore>(),
|
||||||
|
new GenreMatcher(db)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
var result = await handler.Handle(
|
var result = await handler.Handle(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Массовое обогащение метаданными. Главное свойство — осторожность: чужой постер и чужой рейтинг
|
||||||
|
/// у шоу в эфире хуже, чем непроставленные метаданные.
|
||||||
|
/// </summary>
|
||||||
|
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<ShowKind>(), Arg.Any<CancellationToken>())
|
||||||
|
.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<MetadataCandidate> candidates)
|
||||||
|
{
|
||||||
|
var provider = Substitute.For<IMetadataProvider>();
|
||||||
|
provider
|
||||||
|
.SearchShowsAsync(Arg.Any<string>(), Arg.Any<ShowKind>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(candidates);
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<EnrichShowsResultDto> RunAsync(
|
||||||
|
TestDb fixture,
|
||||||
|
IMetadataProvider provider,
|
||||||
|
Guid showId
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var resolver = Substitute.For<IMetadataProviderResolver>();
|
||||||
|
resolver.Resolve("tmdb").Returns(provider);
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var applier = new ShowMetadataApplier(
|
||||||
|
db,
|
||||||
|
resolver,
|
||||||
|
Substitute.For<IImageDownloader>(),
|
||||||
|
Substitute.For<IImageStore>(),
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,13 +55,20 @@ public class ValidateTemplateTests
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>Наполняет группу позициями: проверке важно их число, а не что за ними стоит.</summary>
|
||||||
|
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<IReadOnlyList<TemplateIssueDto>> ValidateAsync(
|
private static async Task<IReadOnlyList<TemplateIssueDto>> ValidateAsync(
|
||||||
TestDb fixture,
|
TestDb fixture,
|
||||||
Guid channelId
|
Guid channelId
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await using var db = fixture.New();
|
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),
|
new ValidateTemplateQuery(channelId),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
@@ -75,7 +82,7 @@ public class ValidateTemplateTests
|
|||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
await using var db = fixture.New();
|
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()),
|
new ValidateTemplateQuery(Guid.NewGuid()),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
@@ -95,7 +102,7 @@ public class ValidateTemplateTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
await using var db = fixture.New();
|
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),
|
new ValidateTemplateQuery(channel.Id),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
@@ -177,7 +184,9 @@ public class ValidateTemplateTests
|
|||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var channel = Channel.Create("Первый", "one", T0);
|
var channel = Channel.Create("Первый", "one", T0);
|
||||||
var group = Group.Create("Маленькая");
|
var group = Group.Create("Маленькая");
|
||||||
group.UpdateStats(3, 3, TimeSpan.FromHours(3), T0);
|
// Позиции настоящие: проверка считает состав, а не кэш статистики, — у динамической группы
|
||||||
|
// кэш отстаёт от библиотеки, и полагаться на него нельзя.
|
||||||
|
FillGroup(group, 3);
|
||||||
|
|
||||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||||
var slot = FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
|
var slot = FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
|
||||||
@@ -212,7 +221,7 @@ public class ValidateTemplateTests
|
|||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var channel = Channel.Create("Первый", "one", T0);
|
var channel = Channel.Create("Первый", "one", T0);
|
||||||
var group = Group.Create("Кино");
|
var group = Group.Create("Кино");
|
||||||
group.UpdateStats(50, 50, TimeSpan.FromHours(50), T0);
|
FillGroup(group, 50);
|
||||||
|
|
||||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||||
FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
|
FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
|
||||||
@@ -255,7 +264,7 @@ public class ValidateTemplateTests
|
|||||||
var group = Group.Create("Вечернее");
|
var group = Group.Create("Вечернее");
|
||||||
group.AddElement(GroupElementKind.Show, mild.Id);
|
group.AddElement(GroupElementKind.Show, mild.Id);
|
||||||
group.AddElement(GroupElementKind.Collection, collection.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 template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||||
var slot = FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
|
var slot = FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Programming.Groups;
|
||||||
using TeleWave.Application.Programming.Templates;
|
using TeleWave.Application.Programming.Templates;
|
||||||
using TeleWave.Application.Programming.Templates.CopyTemplate;
|
using TeleWave.Application.Programming.Templates.CopyTemplate;
|
||||||
using TeleWave.Application.Programming.Templates.CreateTemplate;
|
using TeleWave.Application.Programming.Templates.CreateTemplate;
|
||||||
@@ -81,10 +82,10 @@ public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture)
|
|||||||
var (sourceId, _, _) = await SeedPairAsync(seedDb);
|
var (sourceId, _, _) = await SeedPairAsync(seedDb);
|
||||||
|
|
||||||
await using var db = fixture.CreateContext();
|
await using var db = fixture.CreateContext();
|
||||||
var result = await new ValidateTemplateQueryHandler(db).Handle(
|
var result = await new ValidateTemplateQueryHandler(
|
||||||
new ValidateTemplateQuery(sourceId),
|
db,
|
||||||
default
|
new DynamicGroupResolver(new GroupFilterMatcher(db), new GroupElementResolver(db))
|
||||||
);
|
).Handle(new ValidateTemplateQuery(sourceId), default);
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
// Группа в сиде пустая, а слот занимает лишь два часа суток — обе проверки должны сработать.
|
// Группа в сиде пустая, а слот занимает лишь два часа суток — обе проверки должны сработать.
|
||||||
|
|||||||
@@ -101,6 +101,26 @@ export function GroupFilterPanel({
|
|||||||
<p className="text-xs text-muted-foreground">{t('admin.groups.filter.audienceHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('admin.groups.filter.audienceHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Нижняя граница — под ночные слоты. Ведёт себя не зеркально верхней, поэтому и подсказка своя. */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.groups.filter.minAudience')}</Label>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||||
|
value={filter.minAudience ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ minAudience: e.target.value === '' ? null : (e.target.value as ShowAudience) })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">{t('admin.groups.filter.any')}</option>
|
||||||
|
{SHOW_AUDIENCES.map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{t(`admin.shows.audiences.${value}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.groups.filter.minAudienceHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.groups.filter.year')}</Label>
|
<Label>{t('admin.groups.filter.year')}</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -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<string>(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 (
|
||||||
|
<div className="crt-panel flex flex-wrap items-center gap-2 rounded-md px-3 py-2 text-sm">
|
||||||
|
<span className="font-medium">
|
||||||
|
{t('admin.shows.bulk.selected', { count: showIds.length })}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Select value={genreId || 'none'} onValueChange={(v) => setGenreId(v === 'none' ? '' : v)}>
|
||||||
|
<SelectTrigger className="h-8 w-48">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">{t('admin.shows.bulk.noGenre')}</SelectItem>
|
||||||
|
{(genres ?? []).map((genre) => (
|
||||||
|
<SelectItem key={genre.id} value={genre.id}>
|
||||||
|
{genre.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={audience} onValueChange={setAudience}>
|
||||||
|
<SelectTrigger className="h-8 w-64 whitespace-nowrap">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={KEEP}>{t('admin.shows.bulk.keepAudience')}</SelectItem>
|
||||||
|
<SelectItem value={AUDIENCE_UNSET}>{t('admin.shows.audienceUnset')}</SelectItem>
|
||||||
|
{SHOW_AUDIENCES.map((value) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{t(`admin.shows.audiences.${value}`)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button size="sm" disabled={nothingToApply || tag.isPending} onClick={() => tag.mutate()}>
|
||||||
|
<Tags className="h-4 w-4" />
|
||||||
|
{t('admin.shows.bulk.apply')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{providers && providers.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={enrich.isPending}
|
||||||
|
title={t('admin.shows.bulk.enrichHint')}
|
||||||
|
onClick={() => enrich.mutate()}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
{enrich.isPending
|
||||||
|
? t('admin.shows.bulk.enriching')
|
||||||
|
: t('admin.shows.bulk.enrich', { provider: providers[0].toUpperCase() })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button size="sm" variant="ghost" onClick={onDone}>
|
||||||
|
{t('admin.shows.bulk.clear')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { sortRows, useTableSort } from '@/shared/lib/table-sort'
|
|||||||
import { SortHeader } from '@/shared/ui/sortable'
|
import { SortHeader } from '@/shared/ui/sortable'
|
||||||
import { listGenres } from '@/features/admin/genres/api'
|
import { listGenres } from '@/features/admin/genres/api'
|
||||||
import { createShow, deleteShow, listShows } from './api'
|
import { createShow, deleteShow, listShows } from './api'
|
||||||
|
import { BulkTagBar } from './BulkTagBar'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
@@ -40,6 +41,8 @@ export function ShowsPanel() {
|
|||||||
|
|
||||||
// Фильтр по жанру — серверный: в списке видно только основной жанр, а отбирать нужно и по остальным.
|
// Фильтр по жанру — серверный: в списке видно только основной жанр, а отбирать нужно и по остальным.
|
||||||
const [genreFilter, setGenreFilter] = useState('all')
|
const [genreFilter, setGenreFilter] = useState('all')
|
||||||
|
// Отмеченные для массовых действий. Живут отдельно от фильтров: сузил список — выбор не потерялся.
|
||||||
|
const [selected, setSelected] = useState<string[]>([])
|
||||||
const { data: genres } = useQuery({ queryKey: qk.genres.all, queryFn: listGenres })
|
const { data: genres } = useQuery({ queryKey: qk.genres.all, queryFn: listGenres })
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: qk.shows.byGenre(genreFilter),
|
queryKey: qk.shows.byGenre(genreFilter),
|
||||||
@@ -171,10 +174,26 @@ export function ShowsPanel() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selected.length > 0 && <BulkTagBar showIds={selected} onDone={() => setSelected([])} />}
|
||||||
|
|
||||||
<div className="crt-panel overflow-x-auto rounded-md">
|
<div className="crt-panel overflow-x-auto rounded-md">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b border-border text-left text-muted-foreground">
|
<thead className="border-b border-border text-left text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th className="w-8 px-4 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={t('admin.shows.bulk.selectPage')}
|
||||||
|
checked={pageItems.length > 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)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<SortHeader
|
<SortHeader
|
||||||
label={t('admin.shows.name')}
|
label={t('admin.shows.name')}
|
||||||
sortKey="name"
|
sortKey="name"
|
||||||
@@ -217,13 +236,27 @@ export function ShowsPanel() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-4 py-3 text-muted-foreground" colSpan={7}>
|
<td className="px-4 py-3 text-muted-foreground" colSpan={8}>
|
||||||
{t('common.loading')}
|
{t('common.loading')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{pageItems.map((show) => (
|
{pageItems.map((show) => (
|
||||||
<tr key={show.id} className="border-b border-border last:border-0">
|
<tr key={show.id} className="border-b border-border last:border-0">
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={show.name}
|
||||||
|
checked={selected.includes(show.id)}
|
||||||
|
onChange={() =>
|
||||||
|
setSelected((current) =>
|
||||||
|
current.includes(show.id)
|
||||||
|
? current.filter((id) => id !== show.id)
|
||||||
|
: [...current, show.id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<Link
|
<Link
|
||||||
to="/admin/shows/$showId"
|
to="/admin/shows/$showId"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { apiRequest } from '@/shared/api/client'
|
import { apiRequest } from '@/shared/api/client'
|
||||||
import type {
|
import type {
|
||||||
CreatedIdResponse,
|
CreatedIdResponse,
|
||||||
|
EnrichShowsResult,
|
||||||
MetadataCandidate,
|
MetadataCandidate,
|
||||||
MissingEpisodesReport,
|
MissingEpisodesReport,
|
||||||
MissingSeasonsReport,
|
MissingSeasonsReport,
|
||||||
@@ -53,6 +54,25 @@ export function setShowOriginalName(id: string, originalName: string | null) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Массовая разметка отмеченных шоу: жанры и рейтинг применяются независимо. */
|
||||||
|
export function bulkTagShows(body: {
|
||||||
|
showIds: string[]
|
||||||
|
genreIds?: string[]
|
||||||
|
replaceGenres?: boolean
|
||||||
|
audience?: ShowAudience | null
|
||||||
|
setAudience?: boolean
|
||||||
|
}) {
|
||||||
|
return apiRequest<{ updated: number }>('/admin/shows/bulk/tag', { method: 'POST', body })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Массовое обогащение: найти метаданные по названию и применить тем, у кого нет привязки. */
|
||||||
|
export function bulkEnrichShows(showIds: string[], provider: string) {
|
||||||
|
return apiRequest<EnrichShowsResult>('/admin/shows/bulk/enrich', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { showIds, provider },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteShow(id: string) {
|
export function deleteShow(id: string) {
|
||||||
return apiRequest<void>(`/admin/shows/${id}`, { method: 'DELETE' })
|
return apiRequest<void>(`/admin/shows/${id}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ export type GroupFilter = {
|
|||||||
showKinds?: ShowKind[] | null
|
showKinds?: ShowKind[] | null
|
||||||
genreIds?: string[] | null
|
genreIds?: string[] | null
|
||||||
maxAudience?: ShowAudience | null
|
maxAudience?: ShowAudience | null
|
||||||
|
minAudience?: ShowAudience | null
|
||||||
yearMin?: number | null
|
yearMin?: number | null
|
||||||
yearMax?: number | null
|
yearMax?: number | null
|
||||||
unitMinutesMin?: number | null
|
unitMinutesMin?: number | null
|
||||||
@@ -253,6 +254,12 @@ export type GroupSummaryDto = {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Итог массового обогащения метаданными: что применилось и что пропущено с причиной. */
|
||||||
|
export type EnrichShowsResult = {
|
||||||
|
applied: number
|
||||||
|
skipped: { showId: string; showName: string; reason: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
/** Откуда группа берёт состав: явный список либо вычисление правилом. */
|
/** Откуда группа берёт состав: явный список либо вычисление правилом. */
|
||||||
export type GroupMode = 'Static' | 'Dynamic'
|
export type GroupMode = 'Static' | 'Dynamic'
|
||||||
|
|
||||||
|
|||||||
@@ -134,12 +134,15 @@ export const en = {
|
|||||||
},
|
},
|
||||||
filter: {
|
filter: {
|
||||||
title: 'Selection rule',
|
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',
|
elementKinds: 'What to search',
|
||||||
showKinds: 'Show type',
|
showKinds: 'Show type',
|
||||||
genres: 'Genres',
|
genres: 'Genres',
|
||||||
genresHint: 'Any of the checked ones.',
|
genresHint: 'Any of the checked ones.',
|
||||||
maxAudience: 'No stricter than',
|
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:
|
audienceHint:
|
||||||
'Ratings are ordered by strictness: G → PG → PG-13 → R → NC-17. Unrated shows stay in the selection.',
|
'Ratings are ordered by strictness: G → PG → PG-13 → R → NC-17. Unrated shows stay in the selection.',
|
||||||
year: 'Year',
|
year: 'Year',
|
||||||
@@ -346,6 +349,21 @@ export const en = {
|
|||||||
genre: 'Genre',
|
genre: 'Genre',
|
||||||
allGenres: 'All genres',
|
allGenres: 'All genres',
|
||||||
otherGenres: 'Other 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',
|
genresEmpty: 'No genres set',
|
||||||
genresEdit: 'Genres',
|
genresEdit: 'Genres',
|
||||||
genresHint:
|
genresHint:
|
||||||
|
|||||||
@@ -134,12 +134,15 @@ export const ru = {
|
|||||||
},
|
},
|
||||||
filter: {
|
filter: {
|
||||||
title: 'Правило набора',
|
title: 'Правило набора',
|
||||||
hint: 'Правило только ищет кандидатов — состав группы остаётся явным списком.',
|
hint: 'В явном списке правило только ищет кандидатов; в группе «по правилу» оно и есть состав.',
|
||||||
elementKinds: 'Что искать',
|
elementKinds: 'Что искать',
|
||||||
showKinds: 'Тип шоу',
|
showKinds: 'Тип шоу',
|
||||||
genres: 'Жанры',
|
genres: 'Жанры',
|
||||||
genresHint: 'Любой из отмеченных.',
|
genresHint: 'Любой из отмеченных.',
|
||||||
maxAudience: 'Возраст не строже',
|
maxAudience: 'Возраст не строже',
|
||||||
|
minAudience: 'Возраст не мягче',
|
||||||
|
minAudienceHint:
|
||||||
|
'Для ночных слотов. В отличие от верхней границы, шоу без рейтинга сюда не попадают — иначе в «взрослое» просочились бы непроставленные мультики.',
|
||||||
audienceHint:
|
audienceHint:
|
||||||
'Рейтинги упорядочены по строгости: G → PG → PG-13 → R → NC-17. Шоу без рейтинга остаются в выборке.',
|
'Рейтинги упорядочены по строгости: G → PG → PG-13 → R → NC-17. Шоу без рейтинга остаются в выборке.',
|
||||||
year: 'Год',
|
year: 'Год',
|
||||||
@@ -347,6 +350,21 @@ export const ru = {
|
|||||||
genre: 'Жанр',
|
genre: 'Жанр',
|
||||||
allGenres: 'Все жанры',
|
allGenres: 'Все жанры',
|
||||||
otherGenres: 'Остальные жанры',
|
otherGenres: 'Остальные жанры',
|
||||||
|
bulk: {
|
||||||
|
selected: 'Отмечено: {{count}}',
|
||||||
|
selectPage: 'Отметить все на странице',
|
||||||
|
noGenre: 'Жанр не менять',
|
||||||
|
keepAudience: 'Рейтинг не менять',
|
||||||
|
apply: 'Применить',
|
||||||
|
tagged: 'Обновлено шоу: {{count}}',
|
||||||
|
clear: 'Снять выбор',
|
||||||
|
enrich: 'Метаданные из {{provider}}',
|
||||||
|
enrichHint:
|
||||||
|
'Найти по названию и применить — только тем, у кого источник ещё не привязан. Неоднозначные совпадения пропускаются.',
|
||||||
|
enriching: 'Ищем…',
|
||||||
|
enriched: 'Метаданные применены: {{count}}',
|
||||||
|
moreSkipped: 'Ещё пропущено: {{count}}',
|
||||||
|
},
|
||||||
genresEmpty: 'Жанры не проставлены',
|
genresEmpty: 'Жанры не проставлены',
|
||||||
genresEdit: 'Жанры',
|
genresEdit: 'Жанры',
|
||||||
genresHint:
|
genresHint:
|
||||||
|
|||||||
Reference in New Issue
Block a user