Implement collection suggestions and bulk show addition features
ci / build-backend (push) Successful in 2m41s
ci / build-frontend (push) Successful in 44s
ci / tests (push) Successful in 2m33s
ci / sonar (push) Successful in 5m12s

Added new API endpoints for suggesting collections based on existing shows and for bulk adding shows to collections. Enhanced the backend with necessary logic and DTOs to support these features. Updated the frontend to include new components for displaying collection suggestions and managing bulk additions, improving the user experience for collection management. Localization updates were made to support these new features in both English and Russian.
This commit is contained in:
Leonid Pershin
2026-07-27 05:39:23 +03:00
parent 1a7f73a5bd
commit 5ad28b2966
39 changed files with 2710 additions and 10 deletions
@@ -2,6 +2,7 @@ using LiteCqrs;
using TeleWave.Api.Common; using TeleWave.Api.Common;
using TeleWave.Application.Library.Collections; using TeleWave.Application.Library.Collections;
using TeleWave.Application.Library.Collections.AddCollectionShow; using TeleWave.Application.Library.Collections.AddCollectionShow;
using TeleWave.Application.Library.Collections.AddShowsToCollection;
using TeleWave.Application.Library.Collections.CreateCollection; using TeleWave.Application.Library.Collections.CreateCollection;
using TeleWave.Application.Library.Collections.DeleteCollection; using TeleWave.Application.Library.Collections.DeleteCollection;
using TeleWave.Application.Library.Collections.GetCollection; using TeleWave.Application.Library.Collections.GetCollection;
@@ -9,6 +10,7 @@ using TeleWave.Application.Library.Collections.ListCollections;
using TeleWave.Application.Library.Collections.RemoveCollectionShow; using TeleWave.Application.Library.Collections.RemoveCollectionShow;
using TeleWave.Application.Library.Collections.ReorderCollection; using TeleWave.Application.Library.Collections.ReorderCollection;
using TeleWave.Application.Library.Collections.SetCollectionPoster; using TeleWave.Application.Library.Collections.SetCollectionPoster;
using TeleWave.Application.Library.Collections.Suggest;
using TeleWave.Application.Library.Collections.UpdateCollection; using TeleWave.Application.Library.Collections.UpdateCollection;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
@@ -36,9 +38,62 @@ public static class CollectionEndpoints
admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent); admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/poster-image", SetPoster).Produces(StatusCodes.Status204NoContent); admin.MapPut("/{id:guid}/poster-image", SetPoster).Produces(StatusCodes.Status204NoContent);
// Сборка франшиз: массовое добавление отмеченных шоу и предложения по библиотеке.
admin.MapPost("/bulk/shows", AddShows).Produces<AddShowsToCollectionResultDto>();
admin
.MapGet("/suggestions", Suggestions)
.Produces<IReadOnlyList<CollectionSuggestionDto>>();
admin
.MapPost("/suggestions", CreateFromSuggestion)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
return app; return app;
} }
private static async Task<IResult> AddShows(
AddShowsToCollectionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddShowsToCollectionCommand(
body.ShowIds,
body.CollectionId,
body.NewCollectionName
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Suggestions(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new SuggestCollectionsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> CreateFromSuggestion(
CreateCollectionFromSuggestionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateCollectionFromSuggestionCommand(body.Key),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/collections/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListCollections( private static async Task<IResult> ListCollections(
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
@@ -161,3 +216,12 @@ public sealed record CollectionShowBody(Guid ShowId);
public sealed record ReorderCollectionBody(IReadOnlyList<Guid> ShowIdsInOrder); public sealed record ReorderCollectionBody(IReadOnlyList<Guid> ShowIdsInOrder);
public sealed record CollectionPosterBody(Guid? ImageId); public sealed record CollectionPosterBody(Guid? ImageId);
/// <summary>Куда складывать: существующая коллекция либо новая по имени.</summary>
public sealed record AddShowsToCollectionBody(
IReadOnlyList<Guid> ShowIds,
Guid? CollectionId = null,
string? NewCollectionName = null
);
public sealed record CreateCollectionFromSuggestionBody(string Key);
@@ -6,6 +6,7 @@ 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;
using TeleWave.Application.Library.GetShowUsage;
using TeleWave.Application.Library.ListShows; using TeleWave.Application.Library.ListShows;
using TeleWave.Application.Library.RemoveEpisode; using TeleWave.Application.Library.RemoveEpisode;
using TeleWave.Application.Library.RenameShow; using TeleWave.Application.Library.RenameShow;
@@ -35,6 +36,7 @@ public static class ShowEndpoints
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
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.MapGet("/{id:guid}/usage", GetUsage).Produces<ShowUsageDto>();
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent); admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
// Массовые операции над отмеченными шоу: разметка руками и обогащение из источника. // Массовые операции над отмеченными шоу: разметка руками и обогащение из источника.
@@ -179,6 +181,16 @@ public static class ShowEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> GetUsage(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetShowUsageQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> BulkTag( private static async Task<IResult> BulkTag(
BulkTagShowsBody body, BulkTagShowsBody body,
ISender sender, ISender sender,
@@ -5,6 +5,7 @@ using LiteCqrs.DependencyInjection;
using Microsoft.Extensions.DependencyInjection; 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.Collections.Suggest;
using TeleWave.Application.Library.Genres; using TeleWave.Application.Library.Genres;
using TeleWave.Application.Metadata; using TeleWave.Application.Metadata;
using TeleWave.Application.Programming.Groups; using TeleWave.Application.Programming.Groups;
@@ -38,6 +39,7 @@ public static class DependencyInjection
services.AddScoped<BumperSpecLoader>(); services.AddScoped<BumperSpecLoader>();
services.AddScoped<GenreMatcher>(); services.AddScoped<GenreMatcher>();
services.AddScoped<ShowMetadataApplier>(); services.AddScoped<ShowMetadataApplier>();
services.AddScoped<CollectionSuggestionBuilder>();
services.AddScoped<GroupElementResolver>(); services.AddScoped<GroupElementResolver>();
services.AddScoped<GroupFilterMatcher>(); services.AddScoped<GroupFilterMatcher>();
services.AddScoped<DynamicGroupResolver>(); services.AddScoped<DynamicGroupResolver>();
@@ -0,0 +1,22 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.AddShowsToCollection;
/// <summary>
/// Отправляет несколько шоу во франшизу разом. Коллекция либо уже есть
/// (<paramref name="CollectionId"/>), либо заводится по имени
/// (<paramref name="NewCollectionName"/>) — иначе на сборку франшизы уходило бы два экрана.
///
/// Порядок добавления — по году выпуска: сиквелы почти всегда идут так, а исключения правятся
/// перетаскиванием в карточке коллекции.
/// </summary>
public sealed record AddShowsToCollectionCommand(
IReadOnlyList<Guid> ShowIds,
Guid? CollectionId = null,
string? NewCollectionName = null
) : ICommand<Result<AddShowsToCollectionResultDto>>;
/// <param name="CollectionId">Куда в итоге сложили — новая коллекция или переданная.</param>
/// <param name="Added">Сколько шоу реально добавлено (уже входящие пропускаются).</param>
public sealed record AddShowsToCollectionResultDto(Guid CollectionId, string Name, int Added);
@@ -0,0 +1,62 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.Collections.AddShowsToCollection;
public sealed class AddShowsToCollectionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddShowsToCollectionCommand, Result<AddShowsToCollectionResultDto>>
{
public async Task<Result<AddShowsToCollectionResultDto>> Handle(
AddShowsToCollectionCommand command,
CancellationToken cancellationToken
)
{
var showIds = command.ShowIds.Distinct().ToList();
var shows = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Name,
s.Year,
})
.ToListAsync(cancellationToken);
if (shows.Count == 0)
return Result.Failure<AddShowsToCollectionResultDto>(ShowErrors.NotFound);
var collection = await ResolveCollectionAsync(command, cancellationToken);
if (collection is null)
return Result.Failure<AddShowsToCollectionResultDto>(CollectionErrors.NotFound);
// Год — главный порядок франшизы; без года часть уходит в конец, там её и переставят руками.
var ordered = shows
.OrderBy(s => s.Year is null)
.ThenBy(s => s.Year)
.ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase);
var added = ordered.Count(show => collection.AddShow(show.Id) is not null);
return Result.Success(
new AddShowsToCollectionResultDto(collection.Id, collection.Name, added)
);
}
private async Task<Collection?> ResolveCollectionAsync(
AddShowsToCollectionCommand command,
CancellationToken cancellationToken
)
{
if (command.CollectionId is { } id)
return await dbContext
.Collections.Include(c => c.Items)
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
var created = Collection.Create(command.NewCollectionName!);
dbContext.Collections.Add(created);
return created;
}
}
@@ -0,0 +1,19 @@
using FluentValidation;
namespace TeleWave.Application.Library.Collections.AddShowsToCollection;
public sealed class AddShowsToCollectionCommandValidator
: AbstractValidator<AddShowsToCollectionCommand>
{
public AddShowsToCollectionCommandValidator()
{
RuleFor(x => x.ShowIds).NotEmpty();
// Либо кладём в существующую, либо заводим новую — «ни то ни другое» указывает на ошибку UI.
RuleFor(x => x)
.Must(x =>
x.CollectionId is not null || !string.IsNullOrWhiteSpace(x.NewCollectionName)
)
.WithMessage("Не указано, в какую коллекцию складывать.");
RuleFor(x => x.NewCollectionName).MaximumLength(256);
}
}
@@ -18,4 +18,14 @@ public static class CollectionErrors
"Collections.ShowNotInCollection", "Collections.ShowNotInCollection",
"Шоу не входит в коллекцию." "Шоу не входит в коллекцию."
); );
public static readonly Error SuggestionNotFound = Error.NotFound(
"Collections.SuggestionNotFound",
"Предложение устарело — библиотека изменилась. Обновите список предложений."
);
public static readonly Error SuggestionAlreadyCreated = Error.Conflict(
"Collections.SuggestionAlreadyCreated",
"Коллекция с таким названием уже есть."
);
} }
@@ -0,0 +1,239 @@
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.Collections.Suggest;
/// <summary>Предложение франшизы: имя и части в предлагаемом порядке.</summary>
public sealed record CollectionSuggestion(
string Key,
CollectionSuggestionSource Source,
string Name,
IReadOnlyList<CollectionSuggestionPartDto> Parts
);
/// <summary>
/// Ищет, что стоит собрать в коллекцию, среди полнометражек, ещё не входящих ни в одну.
///
/// Два источника, и они не равны по надёжности. Франшиза из метаданных — это факт: TMDb сам считает
/// эти фильмы одним целым. Догадка по названиям — эвристика для библиотеки без метаданных, и она
/// намеренно осторожна: совпадать должно осмысленное начало названия, а не первое слово, иначе
/// «Человек-паук» и «Человек дождя» уедут в одну франшизу.
///
/// Решение всё равно за человеком: предложение показывает состав целиком, а создаётся коллекция
/// кнопкой. Сериалы сюда не попадают — у них своя форма продолжения, сезоны.
/// </summary>
public sealed class CollectionSuggestionBuilder(IAppDbContext dbContext)
{
/// <summary>Меньше двух частей — не франшиза.</summary>
private const int MinParts = 2;
/// <summary>
/// Со скольких общих слов начало названия само по себе считается признаком франшизы:
/// «Harry Potter and the …» случайным совпадением не бывает, а «Человек …» — бывает.
/// </summary>
private const int StrongCommonWords = 3;
/// <summary>
/// Чем продолжение отличается от однофамильца: номер («Рокки 2», «Рокки III»), слово «часть»
/// либо подзаголовок через двоеточие или тире («Матрица: Перезагрузка»). У «Человек дождя»
/// и «Человек-слон» хвосты — обычные слова, и в одну франшизу они не попадут.
/// </summary>
private static readonly Regex SequelTail = new(
@"^(?:\d{1,2}\b|[IVXivx]{1,4}\b|part\b|часть\b|[:–—-]\s*\S)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
TimeSpan.FromSeconds(1)
);
public async Task<IReadOnlyList<CollectionSuggestion>> BuildAsync(
CancellationToken cancellationToken
)
{
var inCollections = await dbContext
.CollectionItems.AsNoTracking()
.Select(i => i.ShowId)
.Distinct()
.ToListAsync(cancellationToken);
var movies = await dbContext
.Shows.AsNoTracking()
.Where(s => s.Kind == ShowKind.Single && !inCollections.Contains(s.Id))
.Select(s => new MovieRow(
s.Id,
s.Name,
s.OriginalName,
s.Year,
s.FranchiseExternalId,
s.FranchiseName
))
.ToListAsync(cancellationToken);
if (movies.Count < MinParts)
return [];
var suggestions = new List<CollectionSuggestion>();
var taken = new HashSet<Guid>();
foreach (var franchise in ByMetadata(movies))
{
suggestions.Add(franchise);
foreach (var part in franchise.Parts)
taken.Add(part.ShowId);
}
// По названиям добираем только то, что не разобрала метадата: иначе одни и те же фильмы
// попали бы в два предложения сразу и админ выбирал бы между ними вслепую.
suggestions.AddRange(ByName(movies.Where(m => !taken.Contains(m.Id)).ToList()));
return
[
.. suggestions
.OrderBy(s => s.Source)
.ThenByDescending(s => s.Parts.Count)
.ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase),
];
}
private static IEnumerable<CollectionSuggestion> ByMetadata(List<MovieRow> movies)
{
var groups = movies
.Where(m => m.FranchiseExternalId is not null && m.FranchiseName is not null)
.GroupBy(m => m.FranchiseExternalId!)
.Where(g => g.Count() >= MinParts);
foreach (var group in groups)
yield return new CollectionSuggestion(
$"franchise:{group.Key}",
CollectionSuggestionSource.Metadata,
group.First().FranchiseName!,
Parts(group)
);
}
/// <summary>
/// Догадка по названиям для библиотеки без метаданных. Сравниваются целые слова, а не символы:
/// посимвольное совпадение слишком легко ловит однофамильцев («Человек дождя» и «Человек-слон»).
/// Пара считается франшизой, если общее начало длинное либо хвосты обеих частей выглядят
/// продолжением. Имя предложения — общее начало, очищенное от хвостовых предлогов и знаков.
/// </summary>
private static IEnumerable<CollectionSuggestion> ByName(List<MovieRow> movies)
{
var used = new HashSet<Guid>();
// От длинных названий к коротким: так более специфичная франшиза собирается первой.
foreach (var movie in movies.OrderByDescending(m => Title(m).Length))
{
if (used.Contains(movie.Id))
continue;
var words = Words(Title(movie));
var family = movies
.Where(other =>
other.Id != movie.Id
&& !used.Contains(other.Id)
&& SameFranchise(words, Words(Title(other)))
)
.ToList();
if (family.Count == 0)
continue;
var common = family.Aggregate(
words,
(acc, other) => acc[..CommonWordCount(acc, Words(Title(other)))]
);
var name = Clean(string.Join(' ', common));
if (name.Length < 3)
continue;
family.Add(movie);
foreach (var part in family)
used.Add(part.Id);
yield return new CollectionSuggestion(
$"name:{name.ToLowerInvariant()}",
CollectionSuggestionSource.NameGuess,
name,
Parts(family)
);
}
}
/// <summary>Части идут по году: у франшизы это и есть порядок просмотра.</summary>
private static IReadOnlyList<CollectionSuggestionPartDto> Parts(IEnumerable<MovieRow> movies) =>
[
.. movies
.OrderBy(m => m.Year is null)
.ThenBy(m => m.Year)
.ThenBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase)
.Select(m => new CollectionSuggestionPartDto(m.Id, m.Name, m.Year)),
];
/// <summary>Сравниваем по оригинальному названию, если оно есть: у релизов оно стабильнее.</summary>
private static string Title(MovieRow movie) =>
string.IsNullOrWhiteSpace(movie.OriginalName) ? movie.Name : movie.OriginalName;
private static bool SameFranchise(string[] first, string[] second)
{
var common = CommonWordCount(first, second);
if (common == 0)
return false;
if (common >= StrongCommonWords)
return true;
return IsSequelTail(first, common) && IsSequelTail(second, common);
}
/// <summary>Хвост после общего начала: пусто — это первая часть, иначе должен выглядеть продолжением.</summary>
private static bool IsSequelTail(string[] words, int common)
{
var tail = string.Join(' ', words.Skip(common)).Trim();
return tail.Length == 0 || SequelTail.IsMatch(tail);
}
private static int CommonWordCount(string[] first, string[] second)
{
var count = 0;
while (
count < first.Length
&& count < second.Length
&& string.Equals(first[count], second[count], StringComparison.CurrentCultureIgnoreCase)
)
count++;
return count;
}
private static string[] Words(string title) =>
title.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
/// <summary>
/// Общее начало обрывается на союзе или знаке: «Harry Potter and the» — это ещё не имя франшизы.
/// Чистим, пока есть что убирать: за один проход «and the» не снять.
/// </summary>
private static string Clean(string prefix)
{
var trimmed = prefix.Trim();
bool changed;
do
{
changed = false;
trimmed = trimmed.TrimEnd(':', '-', '', '—', '.', ',', ' ');
foreach (var tail in (string[])[" и", " and", " the", " part", " часть"])
{
if (!trimmed.EndsWith(tail, StringComparison.CurrentCultureIgnoreCase))
continue;
trimmed = trimmed[..^tail.Length];
changed = true;
}
} while (changed);
return trimmed.Trim();
}
private sealed record MovieRow(
Guid Id,
string Name,
string? OriginalName,
int? Year,
string? FranchiseExternalId,
string? FranchiseName
);
}
@@ -0,0 +1,11 @@
namespace TeleWave.Application.Library.Collections.Suggest;
/// <summary>Насколько предложению можно верить — по этому UI подписывает происхождение.</summary>
public enum CollectionSuggestionSource
{
/// <summary>Франшиза пришла из метаданных (TMDb знает её как единое целое).</summary>
Metadata = 0,
/// <summary>Догадка по названиям: общее начало и нумерация. Требует проверки глазами.</summary>
NameGuess = 1,
}
@@ -0,0 +1,11 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.Suggest;
/// <summary>
/// Создаёт коллекцию по предложению: имя и состав сервер пересобирает по ключу, а не берёт
/// с клиента. Между показом и нажатием библиотека могла измениться — во франшизу должно попасть
/// актуальное.
/// </summary>
public sealed record CreateCollectionFromSuggestionCommand(string Key) : ICommand<Result<Guid>>;
@@ -0,0 +1,38 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.Collections.Suggest;
public sealed class CreateCollectionFromSuggestionCommandHandler(
IAppDbContext dbContext,
CollectionSuggestionBuilder builder
) : ICommandHandler<CreateCollectionFromSuggestionCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateCollectionFromSuggestionCommand command,
CancellationToken cancellationToken
)
{
var suggestions = await builder.BuildAsync(cancellationToken);
var suggestion = suggestions.FirstOrDefault(s => s.Key == command.Key);
if (suggestion is null)
return Result.Failure<Guid>(CollectionErrors.SuggestionNotFound);
var taken = await dbContext.Collections.AnyAsync(
c => c.Name.ToLower() == suggestion.Name.ToLower(),
cancellationToken
);
if (taken)
return Result.Failure<Guid>(CollectionErrors.SuggestionAlreadyCreated);
var collection = Collection.Create(suggestion.Name);
foreach (var part in suggestion.Parts)
collection.AddShow(part.ShowId);
dbContext.Collections.Add(collection);
return Result.Success(collection.Id);
}
}
@@ -0,0 +1,20 @@
using LiteCqrs;
namespace TeleWave.Application.Library.Collections.Suggest;
/// <summary>Какие франшизы имеет смысл собрать из уже загруженных полнометражек.</summary>
public sealed record SuggestCollectionsQuery : IQuery<IReadOnlyList<CollectionSuggestionDto>>;
/// <param name="Key">Ключ предложения — с ним же приходит команда создания.</param>
/// <param name="Source">Откуда взялось: франшиза источника либо догадка по названиям.</param>
/// <param name="Parts">Части в предлагаемом порядке.</param>
/// <param name="AlreadyExists">Коллекция с таким названием уже есть — создавать нечего.</param>
public sealed record CollectionSuggestionDto(
string Key,
CollectionSuggestionSource Source,
string Name,
IReadOnlyList<CollectionSuggestionPartDto> Parts,
bool AlreadyExists
);
public sealed record CollectionSuggestionPartDto(Guid ShowId, string Name, int? Year);
@@ -0,0 +1,40 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Library.Collections.Suggest;
public sealed class SuggestCollectionsQueryHandler(
IAppDbContext dbContext,
CollectionSuggestionBuilder builder
) : IQueryHandler<SuggestCollectionsQuery, IReadOnlyList<CollectionSuggestionDto>>
{
public async Task<IReadOnlyList<CollectionSuggestionDto>> Handle(
SuggestCollectionsQuery query,
CancellationToken cancellationToken
)
{
var suggestions = await builder.BuildAsync(cancellationToken);
if (suggestions.Count == 0)
return [];
var names = await dbContext
.Collections.AsNoTracking()
.Select(c => c.Name)
.ToListAsync(cancellationToken);
var existing = names
.Select(n => n.Trim())
.ToHashSet(StringComparer.CurrentCultureIgnoreCase);
return
[
.. suggestions.Select(s => new CollectionSuggestionDto(
s.Key,
s.Source,
s.Name,
s.Parts,
existing.Contains(s.Name)
)),
];
}
}
@@ -0,0 +1,22 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.GetShowUsage;
/// <summary>
/// Где шоу задействовано: группы, коллекции и каналы. Нужно до удаления — сейчас оно просто
/// отбивается ошибкой «используется», не говоря где, и приходится обходить экраны вручную.
/// </summary>
public sealed record GetShowUsageQuery(Guid ShowId) : IQuery<Result<ShowUsageDto>>;
/// <param name="Groups">Группы, в состав которых шоу входит (в том числе по правилу).</param>
/// <param name="Collections">Франшизы, частью которых оно является.</param>
/// <param name="Channels">Каналы, в чьём расписании оно уже стоит.</param>
public sealed record ShowUsageDto(
IReadOnlyList<ShowUsageRefDto> Groups,
IReadOnlyList<ShowUsageRefDto> Collections,
IReadOnlyList<ShowUsageRefDto> Channels
);
/// <param name="ViaRule">Шоу попало в группу правилом, а не явной позицией.</param>
public sealed record ShowUsageRefDto(Guid Id, string Name, bool ViaRule = false);
@@ -0,0 +1,90 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Groups;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Library.GetShowUsage;
public sealed class GetShowUsageQueryHandler(
IAppDbContext dbContext,
DynamicGroupResolver dynamicResolver
) : IQueryHandler<GetShowUsageQuery, Result<ShowUsageDto>>
{
public async Task<Result<ShowUsageDto>> Handle(
GetShowUsageQuery query,
CancellationToken cancellationToken
)
{
if (!await dbContext.Shows.AnyAsync(s => s.Id == query.ShowId, cancellationToken))
return Result.Failure<ShowUsageDto>(ShowErrors.NotFound);
var collections = await dbContext
.Collections.AsNoTracking()
.Where(c => c.Items.Any(i => i.ShowId == query.ShowId))
.Select(c => new ShowUsageRefDto(c.Id, c.Name, false))
.ToListAsync(cancellationToken);
var channels = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e => e.ShowId == query.ShowId)
.Select(e => e.ChannelId)
.Distinct()
.Join(
dbContext.Channels.AsNoTracking(),
id => id,
channel => channel.Id,
(_, channel) => new ShowUsageRefDto(channel.Id, channel.Name, false)
)
.ToListAsync(cancellationToken);
return Result.Success(
new ShowUsageDto(
await GroupsAsync(query.ShowId, collections, cancellationToken),
collections,
channels
)
);
}
/// <summary>
/// Группы считаем по вычисленному составу: у динамической шоу может входить правилом, без своей
/// позиции, и такое участие для «можно ли удалять» ничем не отличается от явного. Коллекции
/// учитываются заодно — шоу попадает в группу и через франшизу, целиком.
/// </summary>
private async Task<IReadOnlyList<ShowUsageRefDto>> GroupsAsync(
Guid showId,
IReadOnlyList<ShowUsageRefDto> collections,
CancellationToken cancellationToken
)
{
var groups = await dbContext
.Groups.AsNoTracking()
.Include(g => g.Items)
.ToListAsync(cancellationToken);
var collectionIds = collections.Select(c => c.Id).ToHashSet();
var result = new List<ShowUsageRefDto>();
foreach (var group in groups)
{
var composition = await dynamicResolver.ResolveAsync(group, cancellationToken);
var element = composition.FirstOrDefault(e =>
(e.Kind == GroupElementKind.Show && e.Id == showId)
|| (e.Kind == GroupElementKind.Collection && collectionIds.Contains(e.Id))
);
if (element is null)
continue;
result.Add(
new ShowUsageRefDto(group.Id, group.Name, !element.Pinned && HasRule(group))
);
}
return result;
}
private static bool HasRule(Domain.Programming.Group group) =>
group.Mode == GroupMode.Dynamic && group.FilterJson is not null;
}
@@ -32,7 +32,9 @@ public sealed record ShowMetadata(
string? Overview, string? Overview,
string? PosterUrl, string? PosterUrl,
IReadOnlyList<string>? Genres = null, IReadOnlyList<string>? Genres = null,
string? ContentRating = null string? ContentRating = null,
/// <summary>Франшиза по данным источника (у OMDb таких данных нет — null).</summary>
MetadataFranchise? Franchise = null
); );
/// <summary>Метаданные серии (для этапа 2).</summary> /// <summary>Метаданные серии (для этапа 2).</summary>
@@ -43,7 +43,14 @@ public sealed class ShowMetadataApplier(
var posterImageId = await SavePosterAsync(show, meta.PosterUrl, cancellationToken); var posterImageId = await SavePosterAsync(show, meta.PosterUrl, cancellationToken);
show.ApplyMetadata(providerKey, meta.ExternalId, meta.Overview, meta.Year, posterImageId); show.ApplyMetadata(
providerKey,
meta.ExternalId,
meta.Overview,
meta.Year,
posterImageId,
meta.Franchise
);
// Жанры источника перезаписывают текущие, только если хоть один распознан справочником: // Жанры источника перезаписывают текущие, только если хоть один распознан справочником:
// иначе применение метаданных к шоу с руками проставленными жанрами обнуляло бы их. // иначе применение метаданных к шоу с руками проставленными жанрами обнуляло бы их.
@@ -0,0 +1,9 @@
namespace TeleWave.Domain.Library;
/// <summary>
/// Франшиза шоу по данным источника: «Гарри Поттер», «Терминатор». В домене это не сущность, а
/// подсказка от источника — по ней в библиотеке предлагается собрать <see cref="Collection"/>,
/// которую дальше правит человек. Само объединение остаётся его решением: у TMDb во франшизу
/// попадают и спин-оффы, которые в эфир одним произведением не идут.
/// </summary>
public sealed record MetadataFranchise(string ExternalId, string Name);
+15 -1
View File
@@ -135,16 +135,30 @@ public class Show
public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0; public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0;
/// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary> /// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary>
/// <summary>Франшиза шоу по данным источника: её идентификатор и название.</summary>
public string? FranchiseExternalId { get; private set; }
public string? FranchiseName { get; private set; }
public void ApplyMetadata( public void ApplyMetadata(
string provider, string provider,
string externalId, string externalId,
string? description, string? description,
int? year, int? year,
Guid? posterImageId Guid? posterImageId,
MetadataFranchise? franchise = null
) )
{ {
MetadataProvider = provider; MetadataProvider = provider;
MetadataExternalId = externalId; MetadataExternalId = externalId;
// Франшизу запоминаем, а не забываем сразу после применения: по ней потом предлагаются
// коллекции, и перезапрашивать источник ради каждого фильма было бы расточительно.
// Отсутствие в ответе — не повод стирать известное: у OMDb этих данных нет вовсе.
if (franchise is not null)
{
FranchiseExternalId = franchise.ExternalId;
FranchiseName = franchise.Name;
}
if (!string.IsNullOrWhiteSpace(description)) if (!string.IsNullOrWhiteSpace(description))
Description = description; Description = description;
Year = year; Year = year;
@@ -107,10 +107,30 @@ public sealed class TmdbMetadataProvider(
GetString(root, "overview"), GetString(root, "overview"),
PosterUrl(GetString(root, "poster_path")), PosterUrl(GetString(root, "poster_path")),
GenresFrom(root), GenresFrom(root),
movie ? MovieCertification(root) : TvCertification(root) movie ? MovieCertification(root) : TvCertification(root),
FranchiseFrom(root)
); );
} }
/// <summary>
/// Франшиза фильма: TMDb отдаёт её прямо в ответе по фильму (<c>belongs_to_collection</c>),
/// отдельного запроса не нужно. У сериалов этого поля нет.
/// </summary>
private static MetadataFranchise? FranchiseFrom(JsonElement root)
{
if (
!root.TryGetProperty("belongs_to_collection", out var block)
|| block.ValueKind != JsonValueKind.Object
)
return null;
var id = GetInt(block, "id");
var name = GetString(block, "name");
return id is null || string.IsNullOrWhiteSpace(name)
? null
: new MetadataFranchise(id.Value.ToString(CultureInfo.InvariantCulture), name.Trim());
}
/// <summary>Код прокатного релиза в справочнике типов релизов TMDb.</summary> /// <summary>Код прокатного релиза в справочнике типов релизов TMDb.</summary>
private const int TheatricalReleaseType = 3; private const int TheatricalReleaseType = 3;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ShowFranchise : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "FranchiseExternalId",
table: "Shows",
type: "character varying(64)",
maxLength: 64,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "FranchiseName",
table: "Shows",
type: "character varying(256)",
maxLength: 256,
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "FranchiseExternalId", table: "Shows");
migrationBuilder.DropColumn(name: "FranchiseName", table: "Shows");
}
}
}
@@ -585,6 +585,14 @@ namespace TeleWave.Infrastructure.Migrations
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
b.Property<string>("FranchiseExternalId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("FranchiseName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("Kind") b.Property<int>("Kind")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -14,6 +14,8 @@ public class ShowConfiguration : IEntityTypeConfiguration<Show>
builder.Property(x => x.MetadataProvider).HasMaxLength(16); builder.Property(x => x.MetadataProvider).HasMaxLength(16);
builder.Property(x => x.MetadataExternalId).HasMaxLength(64); builder.Property(x => x.MetadataExternalId).HasMaxLength(64);
builder.Property(x => x.OriginalName).HasMaxLength(256); builder.Property(x => x.OriginalName).HasMaxLength(256);
builder.Property(x => x.FranchiseExternalId).HasMaxLength(64);
builder.Property(x => x.FranchiseName).HasMaxLength(256);
builder builder
.HasMany(x => x.Episodes) .HasMany(x => x.Episodes)
@@ -0,0 +1,142 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Library.Collections.AddShowsToCollection;
using TeleWave.Application.Library.Collections.Suggest;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Library;
using Xunit;
namespace TeleWave.Application.Tests.Library;
/// <summary>
/// Сборка франшиз: предложения по метаданным и по названиям плюс массовое добавление в коллекцию.
/// Ошибка здесь означает чужой фильм в середине франшизы, поэтому осторожность важнее полноты.
/// </summary>
public class CollectionSuggestionsTests
{
[Fact]
public async Task SuggestsFranchise_FromMetadata()
{
var fixture = new TestDb();
var first = Movie("Гарри Поттер и философский камень", 2001, franchise: "1241");
var second = Movie("Гарри Поттер и тайная комната", 2002, franchise: "1241");
var other = Movie("Совсем другое", 1999, franchise: "777");
await SeedAsync(fixture, first, second, other);
var suggestions = await BuildAsync(fixture);
var franchise = Assert.Single(
suggestions,
s => s.Source == CollectionSuggestionSource.Metadata
);
Assert.Equal("Франшиза 1241", franchise.Name);
// Порядок — по году: это и есть порядок просмотра.
Assert.Equal([first.Id, second.Id], franchise.Parts.Select(p => p.ShowId));
}
[Fact]
public async Task SuggestsByName_WhenMetadataMissing()
{
var fixture = new TestDb();
var first = Movie("Рокки", 1976);
var second = Movie("Рокки 2", 1979);
await SeedAsync(fixture, first, second);
var suggestions = await BuildAsync(fixture);
var guess = Assert.Single(
suggestions,
s => s.Source == CollectionSuggestionSource.NameGuess
);
Assert.Equal(2, guess.Parts.Count);
}
[Fact]
public async Task DoesNotGuess_OnShortCommonStart()
{
// «Человек-паук» и «Человек дождя» — не франшиза, хотя начало совпадает.
var fixture = new TestDb();
await SeedAsync(fixture, Movie("Человек дождя", 1988), Movie("Человек-слон", 1980));
var suggestions = await BuildAsync(fixture);
Assert.DoesNotContain(suggestions, s => s.Source == CollectionSuggestionSource.NameGuess);
}
[Fact]
public async Task SkipsShows_AlreadyInCollections()
{
var fixture = new TestDb();
var first = Movie("Рокки", 1976, franchise: "1241");
var second = Movie("Рокки 2", 1979, franchise: "1241");
var collection = Collection.Create("Рокки");
collection.AddShow(first.Id);
await using (var seed = fixture.New())
{
seed.Shows.AddRange(first, second);
seed.Collections.Add(collection);
await seed.SaveChangesAsync(CancellationToken.None);
}
var suggestions = await BuildAsync(fixture);
// Одна часть уже разобрана — на франшизу оставшегося не хватает.
Assert.Empty(suggestions);
}
[Fact]
public async Task AddShowsToCollection_CreatesNewAndOrdersByYear()
{
var fixture = new TestDb();
var later = Movie("Вторая", 1979);
var earlier = Movie("Первая", 1976);
await SeedAsync(fixture, later, earlier);
await using var db = fixture.New();
var result = await new AddShowsToCollectionCommandHandler(db).Handle(
new AddShowsToCollectionCommand([later.Id, earlier.Id], NewCollectionName: "Франшиза"),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
await using var check = fixture.New();
var collection = check.Collections.Include(c => c.Items).Single();
Assert.Equal(2, result.Value.Added);
Assert.Equal(
[earlier.Id, later.Id],
collection.Items.OrderBy(i => i.Position).Select(i => i.ShowId)
);
}
private static Show Movie(string name, int? year, string? franchise = null)
{
var show = Show.Create(name, ShowKind.Single);
if (franchise is not null)
show.ApplyMetadata(
"tmdb",
Guid.NewGuid().ToString("N"),
null,
year,
null,
new MetadataFranchise(franchise, $"Франшиза {franchise}")
);
else
show.ApplyMetadata("manual", Guid.NewGuid().ToString("N"), null, year, null);
return show;
}
private static async Task SeedAsync(TestDb fixture, params Show[] shows)
{
await using var seed = fixture.New();
seed.Shows.AddRange(shows);
await seed.SaveChangesAsync(CancellationToken.None);
}
private static async Task<IReadOnlyList<CollectionSuggestion>> BuildAsync(TestDb fixture)
{
await using var db = fixture.New();
return await new CollectionSuggestionBuilder(db).BuildAsync(CancellationToken.None);
}
}
@@ -0,0 +1,95 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ChevronDown, ChevronRight, Plus, Wand2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import type { CollectionSuggestionDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { toast } from '@/shared/ui/toast-store'
import { createCollectionFromSuggestion, suggestCollections } from './api'
/**
* Что стоит собрать во франшизу. Состав виден целиком до создания по названиям иногда
* промахивается, и проверить глазами должно быть проще, чем разбирать потом.
*/
export function CollectionSuggestions() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const onError = useApiError()
const [collapsed, setCollapsed] = useState(false)
const { data, isLoading } = useQuery({
queryKey: qk.collections.suggestions,
queryFn: suggestCollections,
})
const create = useMutation({
mutationFn: (suggestion: CollectionSuggestionDto) =>
createCollectionFromSuggestion(suggestion.key),
onSuccess: () => {
toast.success(t('admin.collections.suggestions.created'))
void queryClient.invalidateQueries({ queryKey: qk.collections.all })
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
},
onError,
})
if (isLoading || !data || data.length === 0) return null
return (
<div className="flex flex-col gap-2">
<button
type="button"
className="flex items-center gap-2 self-start text-sm font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
onClick={() => setCollapsed((v) => !v)}
>
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
<Wand2 className="h-4 w-4" />
{t('admin.collections.suggestions.title')}
<Badge variant="muted">{data.filter((s) => !s.alreadyExists).length}</Badge>
</button>
{!collapsed && (
<>
<p className="text-xs text-muted-foreground">{t('admin.collections.suggestions.hint')}</p>
<ul className="crt-panel divide-y divide-border rounded-md text-sm">
{data.map((suggestion) => (
<li key={suggestion.key} className="flex flex-col gap-1 px-4 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium">{suggestion.name}</span>
<Badge variant={suggestion.source === 'Metadata' ? 'default' : 'muted'}>
{t(`admin.collections.suggestions.sources.${suggestion.source}`)}
</Badge>
<span className="text-muted-foreground">
{t('admin.collections.suggestions.parts', { count: suggestion.parts.length })}
</span>
{suggestion.alreadyExists ? (
<Badge variant="muted">{t('admin.collections.suggestions.exists')}</Badge>
) : (
<Button
size="sm"
variant="outline"
disabled={create.isPending}
onClick={() => create.mutate(suggestion)}
>
<Plus className="h-4 w-4" />
{t('common.create')}
</Button>
)}
</div>
{/* Состав в предлагаемом порядке: по нему и видно, промахнулась ли догадка. */}
<p className="text-xs text-muted-foreground">
{suggestion.parts
.map((part) => (part.year ? `${part.name} (${part.year})` : part.name))
.join(' → ')}
</p>
</li>
))}
</ul>
</>
)}
</div>
)
}
@@ -10,6 +10,7 @@ import { Input } from '@/shared/ui/input'
import { sortRows, useTableSort } from '@/shared/lib/table-sort' import { sortRows, useTableSort } from '@/shared/lib/table-sort'
import { SortHeader } from '@/shared/ui/sortable' import { SortHeader } from '@/shared/ui/sortable'
import { createCollection, deleteCollection, listCollections } from './api' import { createCollection, deleteCollection, listCollections } from './api'
import { CollectionSuggestions } from './CollectionSuggestions'
export function CollectionsPanel() { export function CollectionsPanel() {
const { t } = useTranslation() const { t } = useTranslation()
@@ -68,6 +69,8 @@ export function CollectionsPanel() {
</Button> </Button>
</div> </div>
<CollectionSuggestions />
<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">
+30 -1
View File
@@ -1,5 +1,10 @@
import { apiRequest } from '@/shared/api/client' import { apiRequest } from '@/shared/api/client'
import type { CollectionDto, CollectionSummaryDto, CreatedIdResponse } from '@/shared/api/types' import type {
CollectionDto,
CollectionSuggestionDto,
CollectionSummaryDto,
CreatedIdResponse,
} from '@/shared/api/types'
export function listCollections() { export function listCollections() {
return apiRequest<CollectionSummaryDto[]>('/admin/collections') return apiRequest<CollectionSummaryDto[]>('/admin/collections')
@@ -21,6 +26,30 @@ export function deleteCollection(id: string) {
return apiRequest<void>(`/admin/collections/${id}`, { method: 'DELETE' }) return apiRequest<void>(`/admin/collections/${id}`, { method: 'DELETE' })
} }
/** Массовое добавление во франшизу: существующая коллекция либо новая по имени. */
export function addShowsToCollection(body: {
showIds: string[]
collectionId?: string
newCollectionName?: string
}) {
return apiRequest<{ collectionId: string; name: string; added: number }>(
'/admin/collections/bulk/shows',
{ method: 'POST', body },
)
}
/** Какие франшизы имеет смысл собрать из уже загруженных полнометражек. */
export function suggestCollections() {
return apiRequest<CollectionSuggestionDto[]>('/admin/collections/suggestions')
}
export function createCollectionFromSuggestion(key: string) {
return apiRequest<CreatedIdResponse>('/admin/collections/suggestions', {
method: 'POST',
body: { key },
})
}
export function addCollectionShow(id: string, showId: string) { export function addCollectionShow(id: string, showId: string) {
return apiRequest<void>(`/admin/collections/${id}/shows`, { method: 'POST', body: { showId } }) return apiRequest<void>(`/admin/collections/${id}/shows`, { method: 'POST', body: { showId } })
} }
@@ -149,6 +149,10 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
const fresh = (candidates ?? []).filter((c) => !c.alreadyInGroup) const fresh = (candidates ?? []).filter((c) => !c.alreadyInGroup)
const isDynamic = group.mode === 'Dynamic' const isDynamic = group.mode === 'Dynamic'
// Закреплённое, которого черновик правила не находит, в составе всё равно останется.
const pinnedNotMatched = group.items.filter(
(i) => i.pinned && !(candidates ?? []).some((c) => c.elementId === i.elementId),
).length
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -264,7 +268,16 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
> >
<Search className="h-4 w-4" /> {t('admin.groups.find')} <Search className="h-4 w-4" /> {t('admin.groups.find')}
</Button> </Button>
{candidates !== null && ( {candidates !== null && isDynamic && (
// У группы по правилу «добавить найденное» не имеет смысла — правило и есть состав.
// Показываем, каким станет состав, если сохранить черновик правила.
<span className="text-sm text-muted-foreground">
{t('admin.groups.previewComposition', {
count: candidates.length + pinnedNotMatched,
})}
</span>
)}
{candidates !== null && !isDynamic && (
<> <>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{t('admin.groups.found', { total: candidates.length, fresh: fresh.length })} {t('admin.groups.found', { total: candidates.length, fresh: fresh.length })}
@@ -17,6 +17,7 @@ export function GroupsPanel() {
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [name, setName] = useState('') const [name, setName] = useState('')
const [query, setQuery] = useState('')
const { sort, toggle } = useTableSort('name', false) const { sort, toggle } = useTableSort('name', false)
const { data, isLoading } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups }) const { data, isLoading } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
@@ -34,7 +35,11 @@ export function GroupsPanel() {
}) })
const deleteMutation = useMutation({ mutationFn: deleteGroup, onSuccess: invalidate, onError }) const deleteMutation = useMutation({ mutationFn: deleteGroup, onSuccess: invalidate, onError })
const rows = sortRows(data ?? [], sort, { const term = query.trim().toLowerCase()
const matched = term
? (data ?? []).filter((g) => g.name.toLowerCase().includes(term))
: (data ?? [])
const rows = sortRows(matched, sort, {
name: (g) => g.name.toLowerCase(), name: (g) => g.name.toLowerCase(),
items: (g) => g.itemCount, items: (g) => g.itemCount,
units: (g) => g.unitCount, units: (g) => g.unitCount,
@@ -64,6 +69,13 @@ export function GroupsPanel() {
</Button> </Button>
</div> </div>
<Input
className="max-w-xs"
placeholder={t('common.search')}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<GroupSuggestions /> <GroupSuggestions />
<div className="crt-panel overflow-x-auto rounded-md"> <div className="crt-panel overflow-x-auto rounded-md">
@@ -9,6 +9,7 @@ import { Badge, type BadgeProps } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Pager } from '@/shared/ui/pager' import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { useTableSort } from '@/shared/lib/table-sort' import { useTableSort } from '@/shared/lib/table-sort'
import { SortHeader } from '@/shared/ui/sortable' import { SortHeader } from '@/shared/ui/sortable'
import { deleteMedia, getMediaStats, listMedia } from './api' import { deleteMedia, getMediaStats, listMedia } from './api'
@@ -89,9 +90,14 @@ export function MediaPanel() {
const lastActivity = useRef(activity) const lastActivity = useRef(activity)
useEffect(() => { useEffect(() => {
if (lastActivity.current === activity) return if (lastActivity.current === activity) return
// Очередь опустела — говорим об этом вслух: обработка идёт минутами, и следить за чипами,
// ожидая нуля, никто не должен. Сравниваем с прошлым состоянием, чтобы не поздравлять
// с пустой очередью при каждом заходе на экран.
const wasBusy = lastActivity.current !== '0:0'
lastActivity.current = activity lastActivity.current = activity
if (wasBusy && activity === '0:0') toast.success(t('admin.media.queueDrained'))
void refetch() void refetch()
}, [activity, refetch]) }, [activity, refetch, t])
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.media.all }) const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.media.all })
const onError = useApiError() const onError = useApiError()
@@ -0,0 +1,114 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { addShowsToCollection, listCollections } from '@/features/admin/collections/api'
import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
/** Значение «завести новую» в списке коллекций: пустая строка у Radix зарезервирована под сброс. */
const NEW = '__new__'
/**
* Куда сложить отмеченные шоу: в существующую франшизу или в новую. Порядок частей сервер
* расставляет по году правится он потом перетаскиванием в карточке коллекции.
*/
export function AddToCollectionDialog({
showIds,
onClose,
onAdded,
}: Readonly<{ showIds: string[]; onClose: () => void; onAdded: () => void }>) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const onError = useApiError()
const [target, setTarget] = useState(NEW)
const [name, setName] = useState('')
const { data: collections } = useQuery({
queryKey: qk.collections.all,
queryFn: listCollections,
})
const add = useMutation({
mutationFn: () =>
addShowsToCollection({
showIds,
collectionId: target === NEW ? undefined : target,
newCollectionName: target === NEW ? name.trim() : undefined,
}),
onSuccess: (result) => {
toast.success(
t('admin.shows.bulk.addedToCollection', { name: result.name, count: result.added }),
)
void queryClient.invalidateQueries({ queryKey: qk.collections.all })
onAdded()
onClose()
},
onError,
})
const disabled = add.isPending || (target === NEW && !name.trim())
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.shows.bulk.toCollection')}</DialogTitle>
<DialogDescription>{t('admin.shows.bulk.toCollectionHint')}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.collections.title')}</Label>
<Select value={target} onValueChange={setTarget}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NEW}>{t('admin.shows.bulk.newCollection')}</SelectItem>
{(collections ?? []).map((collection) => (
<SelectItem key={collection.id} value={collection.id}>
{collection.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{target === NEW && (
<div className="flex flex-col gap-1.5">
<Label>{t('admin.collections.name')}</Label>
<Input
autoFocus
value={name}
maxLength={256}
onChange={(e) => setName(e.target.value)}
/>
</div>
)}
</div>
<DialogFooter>
<Button size="sm" variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button size="sm" disabled={disabled} onClick={() => add.mutate()}>
{t('admin.shows.bulk.addToCollection', { count: showIds.length })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Sparkles, Tags } from 'lucide-react' import { Layers, Sparkles, Tags } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { listGenres } from '@/features/admin/genres/api' import { listGenres } from '@/features/admin/genres/api'
@@ -10,6 +10,7 @@ import { Button } from '@/shared/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store' import { toast } from '@/shared/ui/toast-store'
import { bulkEnrichShows, bulkTagShows, getMetadataProviders } from './api' import { bulkEnrichShows, bulkTagShows, getMetadataProviders } from './api'
import { AddToCollectionDialog } from './AddToCollectionDialog'
/** Значение «не трогать рейтинг» в селекте — отдельное от «снять рейтинг» (AUDIENCE_UNSET). */ /** Значение «не трогать рейтинг» в селекте — отдельное от «снять рейтинг» (AUDIENCE_UNSET). */
const KEEP = 'keep' const KEEP = 'keep'
@@ -30,6 +31,7 @@ export function BulkTagBar({
const onError = useApiError() const onError = useApiError()
const [genreId, setGenreId] = useState('') const [genreId, setGenreId] = useState('')
const [audience, setAudience] = useState<string>(KEEP) const [audience, setAudience] = useState<string>(KEEP)
const [collectionOpen, setCollectionOpen] = useState(false)
const { data: genres } = useQuery({ queryKey: qk.genres.all, queryFn: listGenres }) const { data: genres } = useQuery({ queryKey: qk.genres.all, queryFn: listGenres })
const { data: providers } = useQuery({ const { data: providers } = useQuery({
@@ -129,9 +131,22 @@ export function BulkTagBar({
</Button> </Button>
)} )}
<Button size="sm" variant="outline" onClick={() => setCollectionOpen(true)}>
<Layers className="h-4 w-4" />
{t('admin.shows.bulk.toCollection')}
</Button>
<Button size="sm" variant="ghost" onClick={onDone}> <Button size="sm" variant="ghost" onClick={onDone}>
{t('admin.shows.bulk.clear')} {t('admin.shows.bulk.clear')}
</Button> </Button>
{collectionOpen && (
<AddToCollectionDialog
showIds={showIds}
onClose={() => setCollectionOpen(false)}
onAdded={invalidate}
/>
)}
</div> </div>
) )
} }
@@ -16,7 +16,7 @@ import { AddEpisodesDialog } from './AddEpisodesDialog'
import { ShowGenresField } from './ShowGenresField' import { ShowGenresField } from './ShowGenresField'
import { ShowMetadataCard } from './ShowMetadataCard' import { ShowMetadataCard } from './ShowMetadataCard'
import { imageUrl } from '@/features/admin/images/api' import { imageUrl } from '@/features/admin/images/api'
import { getShow, removeEpisode, setShowAudience } from './api' import { getShow, getShowUsage, removeEpisode, setShowAudience } from './api'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -30,6 +30,13 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
queryKey: qk.shows.detail(showId), queryKey: qk.shows.detail(showId),
queryFn: () => getShow(showId), queryFn: () => getShow(showId),
}) })
// Где шоу задействовано — отдельным запросом: он обходит все группы, и держать его в карточке
// шоу заодно с остальным значило бы платить за него при каждом обновлении состава.
const { data: usage } = useQuery({
queryKey: qk.shows.usage(showId),
queryFn: () => getShowUsage(showId),
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.shows.detail(showId) }) const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.shows.detail(showId) })
const onError = useApiError() const onError = useApiError()
@@ -134,6 +141,30 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
))} ))}
</p> </p>
)} )}
{/* Где шоу задействовано: без этого «используется» при удалении не объясняет, кем. */}
{usage && usage.groups.length > 0 && (
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{t('admin.shows.inGroups')}:
{usage.groups.map((group) => (
<Link
key={group.id}
to="/admin/groups/$groupId"
params={{ groupId: group.id }}
className="text-primary hover:underline"
>
{group.name}
{group.viaRule && (
<span className="text-muted-foreground"> ({t('admin.groups.byRule')})</span>
)}
</Link>
))}
</p>
)}
{usage && usage.channels.length > 0 && (
<p className="mt-1 text-sm text-muted-foreground">
{t('admin.shows.inChannels')}: {usage.channels.map((c) => c.name).join(', ')}
</p>
)}
</div> </div>
<ShowMetadataCard show={show} onChanged={invalidate} /> <ShowMetadataCard show={show} onChanged={invalidate} />
+6
View File
@@ -9,6 +9,7 @@ import type {
ShowDto, ShowDto,
ShowKind, ShowKind,
ShowSummaryDto, ShowSummaryDto,
ShowUsageDto,
} from '@/shared/api/types' } from '@/shared/api/types'
export function listShows(genreId?: string) { export function listShows(genreId?: string) {
@@ -73,6 +74,11 @@ export function bulkEnrichShows(showIds: string[], provider: string) {
}) })
} }
/** Где шоу задействовано: группы, коллекции, каналы. */
export function getShowUsage(id: string) {
return apiRequest<ShowUsageDto>(`/admin/shows/${id}/usage`)
}
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' })
} }
+3
View File
@@ -34,6 +34,8 @@ export const qk = {
all: ['admin', 'shows'] as const, all: ['admin', 'shows'] as const,
byGenre: (genreId: string | null) => ['admin', 'shows', { genreId }] as const, byGenre: (genreId: string | null) => ['admin', 'shows', { genreId }] as const,
detail: (id: string) => ['admin', 'shows', id] as const, detail: (id: string) => ['admin', 'shows', id] as const,
/** Где шоу задействовано: группы, коллекции, каналы. */
usage: (id: string) => ['admin', 'shows', id, 'usage'] as const,
}, },
groups: { groups: {
@@ -47,6 +49,7 @@ export const qk = {
collections: { collections: {
all: ['admin', 'collections'] as const, all: ['admin', 'collections'] as const,
detail: (id: string) => ['admin', 'collections', id] as const, detail: (id: string) => ['admin', 'collections', id] as const,
suggestions: ['admin', 'collections', 'suggestions'] as const,
}, },
genres: { genres: {
+26
View File
@@ -160,6 +160,20 @@ export type ShowSummaryDto = {
otherGenres: string[] otherGenres: string[]
} }
/** Где шоу задействовано — показывается на его карточке. */
export type ShowUsageDto = {
groups: ShowUsageRefDto[]
collections: ShowUsageRefDto[]
channels: ShowUsageRefDto[]
}
type ShowUsageRefDto = {
id: string
name: string
/** Шоу попало в группу правилом, а не явной позицией. */
viaRule: boolean
}
type ShowGenreDto = { type ShowGenreDto = {
id: string id: string
name: string name: string
@@ -218,6 +232,18 @@ export type CollectionDto = {
items: CollectionItemDto[] items: CollectionItemDto[]
} }
/** Откуда взялось предложение франшизы: данные источника либо догадка по названиям. */
export type CollectionSuggestionSource = 'Metadata' | 'NameGuess'
/** Предложение собрать франшизу: имя и части в предлагаемом порядке. */
export type CollectionSuggestionDto = {
key: string
source: CollectionSuggestionSource
name: string
parts: { showId: string; name: string; year: number | null }[]
alreadyExists: boolean
}
/** Коллекция, в которую входит шоу — для блока на экране шоу. */ /** Коллекция, в которую входит шоу — для блока на экране шоу. */
type ShowCollectionRefDto = { type ShowCollectionRefDto = {
id: string id: string
+18
View File
@@ -116,6 +116,7 @@ export const en = {
restore: 'Restore', restore: 'Restore',
dynamicOrderHint: dynamicOrderHint:
'Order: pinned items first (drag to arrange), then rule matches alphabetically.', 'Order: pinned items first (drag to arrange), then rule matches alphabetically.',
previewComposition: 'With this rule the composition would be: {{count}}',
pendingFound: 'New matches for this rule: {{count}} —', pendingFound: 'New matches for this rule: {{count}} —',
pendingAdd: 'Add all ({{count}})', pendingAdd: 'Add all ({{count}})',
elementKinds: { Show: 'Show', Collection: 'Collection' }, elementKinds: { Show: 'Show', Collection: 'Collection' },
@@ -165,6 +166,14 @@ export const en = {
pickShow: 'Pick a show', pickShow: 'Pick a show',
empty: 'Collection is empty', empty: 'Collection is empty',
orderHint: 'Drag to set the order of the parts — that is the order they air in.', orderHint: 'Drag to set the order of the parts — that is the order they air in.',
suggestions: {
title: 'Franchise suggestions',
hint: 'Built from movies not in any collection yet. Check the parts — the name-based guess sometimes misses.',
parts: '{{count}} parts',
exists: 'already exists',
created: 'Collection created',
sources: { Metadata: 'from metadata', NameGuess: 'by names' },
},
}, },
interstitials: { interstitials: {
title: 'Clips', title: 'Clips',
@@ -281,6 +290,7 @@ export const en = {
uploadingCount: 'Uploading {{done}}/{{total}}', uploadingCount: 'Uploading {{done}}/{{total}}',
cancelAll: 'Cancel all uploads', cancelAll: 'Cancel all uploads',
skippedDuplicates: 'Skipped duplicates: {{count}}', skippedDuplicates: 'Skipped duplicates: {{count}}',
queueDrained: 'Processing finished — the queue is empty',
filterActive: 'Active', filterActive: 'Active',
filterAll: 'All', filterAll: 'All',
name: 'File', name: 'File',
@@ -363,12 +373,20 @@ export const en = {
enriching: 'Searching…', enriching: 'Searching…',
enriched: 'Metadata applied: {{count}}', enriched: 'Metadata applied: {{count}}',
moreSkipped: 'Skipped as well: {{count}}', moreSkipped: 'Skipped as well: {{count}}',
toCollection: 'To collection…',
toCollectionHint:
'The selected shows go into a franchise. Parts are ordered by release year; drag to rearrange them in the collection card.',
newCollection: 'New collection',
addToCollection: 'Add ({{count}})',
addedToCollection: '“{{name}}” updated: {{count}}',
}, },
genresEmpty: 'No genres set', genresEmpty: 'No genres set',
genresEdit: 'Genres', genresEdit: 'Genres',
genresHint: genresHint:
'Pick the genres of the show. The primary one is listed; all of them are used for selection.', 'Pick the genres of the show. The primary one is listed; all of them are used for selection.',
genrePrimary: 'primary', genrePrimary: 'primary',
inGroups: 'In groups',
inChannels: 'Scheduled on',
inCollections: 'Part of collections', inCollections: 'Part of collections',
episodes: 'Episodes', episodes: 'Episodes',
episode: 'Episode', episode: 'Episode',
+18
View File
@@ -116,6 +116,7 @@ export const ru = {
restore: 'Вернуть', restore: 'Вернуть',
dynamicOrderHint: dynamicOrderHint:
'Порядок: сначала закреплённое (перетаскиванием), затем найденное правилом по алфавиту.', 'Порядок: сначала закреплённое (перетаскиванием), затем найденное правилом по алфавиту.',
previewComposition: 'При этом правиле в составе будет: {{count}}',
pendingFound: 'Под правило группы подходит нового: {{count}} —', pendingFound: 'Под правило группы подходит нового: {{count}} —',
pendingAdd: 'Добавить все ({{count}})', pendingAdd: 'Добавить все ({{count}})',
elementKinds: { Show: 'Шоу', Collection: 'Коллекция' }, elementKinds: { Show: 'Шоу', Collection: 'Коллекция' },
@@ -165,6 +166,14 @@ export const ru = {
pickShow: 'Выберите шоу', pickShow: 'Выберите шоу',
empty: 'Коллекция пуста', empty: 'Коллекция пуста',
orderHint: 'Порядок частей задаётся перетаскиванием — в нём они и пойдут в эфир.', orderHint: 'Порядок частей задаётся перетаскиванием — в нём они и пойдут в эфир.',
suggestions: {
title: 'Предложения франшиз',
hint: 'Собрано из полнометражек, не входящих ни в одну коллекцию. Проверьте состав — по названиям догадка иногда промахивается.',
parts: '{{count}} частей',
exists: 'уже есть',
created: 'Коллекция создана',
sources: { Metadata: 'из метаданных', NameGuess: 'по названиям' },
},
}, },
interstitials: { interstitials: {
title: 'Ролики', title: 'Ролики',
@@ -282,6 +291,7 @@ export const ru = {
uploadingCount: 'Загрузка {{done}}/{{total}}', uploadingCount: 'Загрузка {{done}}/{{total}}',
cancelAll: 'Отменить все загрузки', cancelAll: 'Отменить все загрузки',
skippedDuplicates: 'Пропущено дубликатов: {{count}}', skippedDuplicates: 'Пропущено дубликатов: {{count}}',
queueDrained: 'Обработка завершена — очередь пуста',
filterActive: 'Активные', filterActive: 'Активные',
filterAll: 'Все', filterAll: 'Все',
name: 'Файл', name: 'Файл',
@@ -364,12 +374,20 @@ export const ru = {
enriching: 'Ищем…', enriching: 'Ищем…',
enriched: 'Метаданные применены: {{count}}', enriched: 'Метаданные применены: {{count}}',
moreSkipped: 'Ещё пропущено: {{count}}', moreSkipped: 'Ещё пропущено: {{count}}',
toCollection: 'В коллекцию…',
toCollectionHint:
'Отмеченные шоу уйдут во франшизу. Порядок частей — по году выпуска; поправить его можно перетаскиванием в карточке коллекции.',
newCollection: 'Новая коллекция',
addToCollection: 'Добавить ({{count}})',
addedToCollection: '«{{name}}» пополнена: {{count}}',
}, },
genresEmpty: 'Жанры не проставлены', genresEmpty: 'Жанры не проставлены',
genresEdit: 'Жанры', genresEdit: 'Жанры',
genresHint: genresHint:
'Отметьте жанры шоу. Основной показывается в списке; в отборе контента участвуют все.', 'Отметьте жанры шоу. Основной показывается в списке; в отборе контента участвуют все.',
genrePrimary: 'основной', genrePrimary: 'основной',
inGroups: 'В группах',
inChannels: 'В эфире каналов',
inCollections: 'Входит в коллекции', inCollections: 'Входит в коллекции',
episodes: 'Серии', episodes: 'Серии',
episode: 'Серия', episode: 'Серия',