From 91af5895472f9828ada23e1267cef3d10fe6e89d Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 27 Jul 2026 05:03:09 +0300 Subject: [PATCH] Implement dynamic group management features with exclusions and mode handling Enhanced the group management system by introducing dynamic group capabilities, allowing for the exclusion of elements from dynamic groups. Updated the Group and GroupItem models to support a new GroupMode, enabling the distinction between static and dynamic groups. Implemented API endpoints for excluding elements and updated related services to handle group composition based on the selected mode. Improved the frontend to accommodate these changes, including new API functions and UI components for managing exclusions and group modes, thereby enhancing the overall user experience in group management. --- .../TeleWave.Api/Endpoints/GroupEndpoints.cs | 35 +- .../DependencyInjection.cs | 2 + .../Groups/DynamicGroupResolver.cs | 95 ++ .../ExcludeGroupElementCommand.cs | 17 + .../ExcludeGroupElementCommandHandler.cs | 36 + .../FindGroupCandidatesQueryHandler.cs | 85 +- .../Groups/GetGroup/GetGroupQueryHandler.cs | 99 +- .../Programming/Groups/GroupDtos.cs | 24 +- .../Programming/Groups/GroupErrors.cs | 5 + .../Programming/Groups/GroupFilterMatcher.cs | 106 ++ .../Programming/Groups/GroupStatsService.cs | 21 +- .../ListGroups/ListGroupsQueryHandler.cs | 6 +- .../Groups/UpdateGroup/UpdateGroupCommand.cs | 10 +- .../UpdateGroup/UpdateGroupCommandHandler.cs | 43 +- .../Programming/Planning/GroupExpander.cs | 75 +- .../src/TeleWave.Domain/Programming/Group.cs | 91 +- .../TeleWave.Domain/Programming/GroupItem.cs | 9 +- .../Programming/GroupItemRole.cs | 17 + .../TeleWave.Domain/Programming/GroupMode.cs | 14 + .../20260727013448_DynamicGroups.Designer.cs | 1399 +++++++++++++++++ .../20260727013448_DynamicGroups.cs | 38 + .../Migrations/AppDbContextModelSnapshot.cs | 10 + .../Configurations/GroupConfiguration.cs | 6 + .../Broadcast/QueryHandlersTests.cs | 2 +- .../Library/DeleteGuardsTests.cs | 2 +- .../Programming/DynamicGroupsTests.cs | 237 +++ .../Programming/GroupSuggestionsTests.cs | 4 +- .../Support/GroupServices.cs | 17 + .../GridScheduleGeneratorIntegrationTests.cs | 6 +- .../src/features/admin/groups/GroupDetail.tsx | 202 ++- .../src/features/admin/groups/GroupsPanel.tsx | 8 +- frontend/src/features/admin/groups/api.ts | 21 +- frontend/src/shared/api/types.ts | 18 +- frontend/src/shared/lib/locales/en.ts | 15 + frontend/src/shared/lib/locales/ru.ts | 15 + 35 files changed, 2571 insertions(+), 219 deletions(-) create mode 100644 backend/src/TeleWave.Application/Programming/Groups/DynamicGroupResolver.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/ExcludeGroupElement/ExcludeGroupElementCommand.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/ExcludeGroupElement/ExcludeGroupElementCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/GroupFilterMatcher.cs create mode 100644 backend/src/TeleWave.Domain/Programming/GroupItemRole.cs create mode 100644 backend/src/TeleWave.Domain/Programming/GroupMode.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260727013448_DynamicGroups.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260727013448_DynamicGroups.cs create mode 100644 backend/tests/TeleWave.Application.Tests/Programming/DynamicGroupsTests.cs create mode 100644 backend/tests/TeleWave.Application.Tests/Support/GroupServices.cs diff --git a/backend/src/TeleWave.Api/Endpoints/GroupEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/GroupEndpoints.cs index 81643a1..be4e26b 100644 --- a/backend/src/TeleWave.Api/Endpoints/GroupEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/GroupEndpoints.cs @@ -4,6 +4,7 @@ using TeleWave.Application.Programming.Groups; using TeleWave.Application.Programming.Groups.AddGroupElements; using TeleWave.Application.Programming.Groups.CreateGroup; using TeleWave.Application.Programming.Groups.DeleteGroup; +using TeleWave.Application.Programming.Groups.ExcludeGroupElement; using TeleWave.Application.Programming.Groups.FindGroupCandidates; using TeleWave.Application.Programming.Groups.GetGroup; using TeleWave.Application.Programming.Groups.ListGroups; @@ -12,6 +13,7 @@ using TeleWave.Application.Programming.Groups.ReorderGroup; using TeleWave.Application.Programming.Groups.SetGroupItemWeight; using TeleWave.Application.Programming.Groups.Suggest; using TeleWave.Application.Programming.Groups.UpdateGroup; +using TeleWave.Domain.Programming; using TeleWave.Infrastructure.Identity; namespace TeleWave.Api.Endpoints; @@ -50,6 +52,9 @@ public static class GroupEndpoints .Produces(StatusCodes.Status204NoContent); admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent); + // Поправки к вычисленному составу динамической группы. + admin.MapPost("/{id:guid}/exclusions", Exclude).Produces(StatusCodes.Status204NoContent); + return app; } @@ -122,7 +127,7 @@ public static class GroupEndpoints ) { var result = await sender.Send( - new UpdateGroupCommand(id, body.Name, body.Description, body.Filter), + new UpdateGroupCommand(id, body.Name, body.Description, body.Filter, body.Mode), cancellationToken ); return result.ToHttpResult(); @@ -194,6 +199,20 @@ public static class GroupEndpoints return result.ToHttpResult(); } + private static async Task Exclude( + Guid id, + ExcludeElementBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new ExcludeGroupElementCommand(id, body.ElementKind, body.ElementId, body.Excluded), + cancellationToken + ); + return result.ToHttpResult(); + } + private static async Task Reorder( Guid id, ReorderGroupBody body, @@ -209,7 +228,19 @@ public static class GroupEndpoints } } -public sealed record UpdateGroupBody(string Name, string? Description, GroupFilter? Filter); +public sealed record UpdateGroupBody( + string Name, + string? Description, + GroupFilter? Filter, + GroupMode? Mode = null +); + +/// Исключить элемент из динамической группы либо вернуть его правилу. +public sealed record ExcludeElementBody( + GroupElementKind ElementKind, + Guid ElementId, + bool Excluded +); public sealed record FindCandidatesBody(GroupFilter? Filter); diff --git a/backend/src/TeleWave.Application/DependencyInjection.cs b/backend/src/TeleWave.Application/DependencyInjection.cs index c26e6cd..ca3369a 100644 --- a/backend/src/TeleWave.Application/DependencyInjection.cs +++ b/backend/src/TeleWave.Application/DependencyInjection.cs @@ -37,6 +37,8 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/TeleWave.Application/Programming/Groups/DynamicGroupResolver.cs b/backend/src/TeleWave.Application/Programming/Groups/DynamicGroupResolver.cs new file mode 100644 index 0000000..ec46227 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/DynamicGroupResolver.cs @@ -0,0 +1,95 @@ +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Groups; + +/// Элемент вычисленного состава: чем он там оказался и с какими порядком/весом идёт в эфир. +/// Закреплён руками (иначе — найден правилом). +public sealed record ResolvedElement( + GroupElementKind Kind, + Guid Id, + int Position, + int Weight, + bool Pinned +); + +/// +/// Считает состав группы. Статическая отдаёт свой список как есть; динамическая — то, что нашло +/// правило, плюс закреплённое и минус исключённое. +/// +/// Порядок динамической группы: сначала закреплённое (в своём порядке — его задавали руками), +/// затем найденное правилом по алфавиту. Последовательным стратегиям нужен детерминированный +/// порядок, а у найденного правилом своего Position нет и быть не может: позиция живёт +/// на строке таблицы, а строки для него нет. Вес у найденного — единица: взвешивать имеет смысл +/// то, что человек выбрал руками. +/// +public sealed class DynamicGroupResolver( + GroupFilterMatcher matcher, + GroupElementResolver elementResolver +) +{ + public async Task> ResolveAsync( + Group group, + CancellationToken cancellationToken + ) + { + if (group.Mode != GroupMode.Dynamic) + return + [ + .. group + .Items.OrderBy(i => i.Position) + .Select(i => new ResolvedElement( + i.ElementKind, + i.ElementId, + i.Position, + i.Weight, + false + )), + ]; + + var pinned = group + .Items.Where(i => i.Role == GroupItemRole.Pinned) + .OrderBy(i => i.Position) + .ToList(); + var excluded = group + .Items.Where(i => i.Role == GroupItemRole.Excluded) + .Select(i => (i.ElementKind, i.ElementId)) + .ToHashSet(); + + var result = new List(); + var position = 0; + foreach (var item in pinned) + { + if (excluded.Contains((item.ElementKind, item.ElementId))) + continue; + result.Add( + new ResolvedElement(item.ElementKind, item.ElementId, position++, item.Weight, true) + ); + } + + // Без правила динамическая группа сводится к своим закреплённым — это не ошибка, а честное + // «правило ещё не задано»; пустоту потом покажет постпроверка расписания. + if (GroupFilter.FromJson(group.FilterJson) is not { } filter) + return result; + + var taken = result.Select(r => (r.Kind, r.Id)).ToHashSet(); + var matched = await matcher.MatchAsync(filter, cancellationToken); + var fresh = matched.Where(e => !excluded.Contains(e) && !taken.Contains(e)).ToList(); + if (fresh.Count == 0) + return result; + + // Разворот нужен и для отсева по длительности единицы, и для алфавитного порядка. + var info = await elementResolver.ResolveAsync(fresh, cancellationToken); + var ordered = fresh + .Where(e => + info.TryGetValue(e, out var element) + && GroupFilterMatcher.MatchesUnitDuration(filter, element) + ) + .OrderBy(e => info[e].Name, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + + foreach (var (kind, id) in ordered) + result.Add(new ResolvedElement(kind, id, position++, GroupItem.DefaultWeight, false)); + + return result; + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/ExcludeGroupElement/ExcludeGroupElementCommand.cs b/backend/src/TeleWave.Application/Programming/Groups/ExcludeGroupElement/ExcludeGroupElementCommand.cs new file mode 100644 index 0000000..4f69a25 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/ExcludeGroupElement/ExcludeGroupElementCommand.cs @@ -0,0 +1,17 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Groups.ExcludeGroupElement; + +/// +/// Исключить элемент из динамической группы ( = true) либо вернуть его +/// правилу. Удалить найденное правилом «насовсем» нельзя — на следующем обращении оно вернулось бы; +/// именно поэтому поправка хранится, а не применяется разово. +/// +public sealed record ExcludeGroupElementCommand( + Guid GroupId, + GroupElementKind ElementKind, + Guid ElementId, + bool Excluded +) : ICommand; diff --git a/backend/src/TeleWave.Application/Programming/Groups/ExcludeGroupElement/ExcludeGroupElementCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/ExcludeGroupElement/ExcludeGroupElementCommandHandler.cs new file mode 100644 index 0000000..4be051f --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/ExcludeGroupElement/ExcludeGroupElementCommandHandler.cs @@ -0,0 +1,36 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Groups.ExcludeGroupElement; + +public sealed class ExcludeGroupElementCommandHandler( + IAppDbContext dbContext, + GroupStatsService stats +) : ICommandHandler +{ + public async Task Handle( + ExcludeGroupElementCommand command, + CancellationToken cancellationToken + ) + { + var group = await dbContext + .Groups.Include(g => g.Items) + .FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken); + if (group is null) + return Result.Failure(GroupErrors.NotFound); + + if (group.Mode != GroupMode.Dynamic) + return Result.Failure(GroupErrors.ExclusionsForDynamicOnly); + + if (command.Excluded) + group.Exclude(command.ElementKind, command.ElementId); + else + group.RemoveExclusion(command.ElementKind, command.ElementId); + + await stats.RecomputeAsync(group, cancellationToken); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/FindGroupCandidates/FindGroupCandidatesQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/FindGroupCandidates/FindGroupCandidatesQueryHandler.cs index c5be0b5..e3f8779 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/FindGroupCandidates/FindGroupCandidatesQueryHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/FindGroupCandidates/FindGroupCandidatesQueryHandler.cs @@ -2,12 +2,12 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; -using TeleWave.Domain.Programming; namespace TeleWave.Application.Programming.Groups.FindGroupCandidates; public sealed class FindGroupCandidatesQueryHandler( IAppDbContext dbContext, + GroupFilterMatcher matcher, GroupElementResolver resolver ) : IQueryHandler>> { @@ -27,36 +27,7 @@ public sealed class FindGroupCandidatesQueryHandler( if (filter is null) return Result.Failure>(GroupErrors.FilterNotSet); - var wantsShows = - filter.ElementKinds is null or { Count: 0 } - || filter.ElementKinds.Contains(GroupElementKind.Show); - var wantsCollections = - filter.ElementKinds is null or { Count: 0 } - || filter.ElementKinds.Contains(GroupElementKind.Collection); - - var matchingShowIds = await MatchShowIdsAsync(filter, cancellationToken); - - var elements = new List<(GroupElementKind Kind, Guid Id)>(); - if (wantsShows) - elements.AddRange(matchingShowIds.Select(id => (GroupElementKind.Show, id))); - - if (wantsCollections) - { - // Коллекция подходит, только если подходят все её части: иначе во «взрослую ночь» через - // франшизу просочилось бы детское, а в «боевики» — комедия из той же серии фильмов. - var collections = await dbContext - .Collections.AsNoTracking() - .Select(c => new { c.Id, ShowIds = c.Items.Select(i => i.ShowId).ToList() }) - .ToListAsync(cancellationToken); - - var matching = matchingShowIds.ToHashSet(); - elements.AddRange( - collections - .Where(c => c.ShowIds.Count > 0 && c.ShowIds.All(matching.Contains)) - .Select(c => (GroupElementKind.Collection, c.Id)) - ); - } - + var elements = await matcher.MatchAsync(filter, cancellationToken); var info = await resolver.ResolveAsync(elements, cancellationToken); var inGroup = group.Items.Select(i => (i.ElementKind, i.ElementId)).ToHashSet(); @@ -65,7 +36,7 @@ public sealed class FindGroupCandidatesQueryHandler( { if (!info.TryGetValue((kind, id), out var element)) continue; - if (!MatchesUnitDuration(filter, element)) + if (!GroupFilterMatcher.MatchesUnitDuration(filter, element)) continue; result.Add( @@ -87,54 +58,4 @@ public sealed class FindGroupCandidatesQueryHandler( result.OrderBy(c => c.ElementName, StringComparer.CurrentCultureIgnoreCase).ToList() ); } - - private async Task> MatchShowIdsAsync( - GroupFilter filter, - CancellationToken cancellationToken - ) - { - var shows = dbContext.Shows.AsNoTracking(); - - if (filter.ShowKinds is { Count: > 0 } kinds) - shows = shows.Where(s => kinds.Contains(s.Kind)); - - // Рейтинги упорядочены по возрастанию строгости, поэтому «не строже» — обычное сравнение. - // Шоу без рейтинга остаются в выборке — ровно как в планировщике (ElementSelector): источники - // проставляют рейтинг далеко не всему, и молчаливое выбрасывание непроставленного оставило бы - // от фильтра «мультики не строже PG» горстку голливудского. - if (filter.MaxAudience is { } maxAudience) - shows = shows.Where(s => s.Audience == null || s.Audience <= maxAudience); - - if (filter.YearMin is { } yearMin) - shows = shows.Where(s => s.Year != null && s.Year >= yearMin); - if (filter.YearMax is { } yearMax) - shows = shows.Where(s => s.Year != null && s.Year <= yearMax); - - // Жанры — «любой из»: шоу редко подходит под все перечисленные сразу. - if (filter.GenreIds is { Count: > 0 } genreIds) - shows = shows.Where(s => s.Genres.Any(g => genreIds.Contains(g.GenreId))); - - return await shows.Select(s => s.Id).ToListAsync(cancellationToken); - } - - /// - /// Длительность проверяется по средней единице, а не по сумме: у сериала в фильтре «серии - /// по 20–25 минут» осмысленна именно длина серии. Элементы без готовых ассетов длительности - /// не имеют — их этот фильтр не отбрасывает, иначе из группы выпадало бы всё, что ещё - /// обрабатывается. - /// - private static bool MatchesUnitDuration(GroupFilter filter, GroupElementInfo element) - { - if (filter.UnitMinutesMin is null && filter.UnitMinutesMax is null) - return true; - if (element.UnitCount == 0 || element.TotalDuration <= TimeSpan.Zero) - return true; - - var averageMinutes = element.TotalDuration.TotalMinutes / element.UnitCount; - if (filter.UnitMinutesMin is { } min && averageMinutes < min) - return false; - if (filter.UnitMinutesMax is { } max && averageMinutes > max) - return false; - return true; - } } diff --git a/backend/src/TeleWave.Application/Programming/Groups/GetGroup/GetGroupQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/GetGroup/GetGroupQueryHandler.cs index 494a898..bb735dd 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/GetGroup/GetGroupQueryHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/GetGroup/GetGroupQueryHandler.cs @@ -2,11 +2,15 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; +using TeleWave.Domain.Programming; namespace TeleWave.Application.Programming.Groups.GetGroup; -public sealed class GetGroupQueryHandler(IAppDbContext dbContext, GroupElementResolver resolver) - : IQueryHandler> +public sealed class GetGroupQueryHandler( + IAppDbContext dbContext, + GroupElementResolver resolver, + DynamicGroupResolver dynamicResolver +) : IQueryHandler> { public async Task> Handle( GetGroupQuery query, @@ -20,47 +24,98 @@ public sealed class GetGroupQueryHandler(IAppDbContext dbContext, GroupElementRe if (group is null) return Result.Failure(GroupErrors.NotFound); + // Состав считаем на месте: у динамической группы он живёт в правиле, и показать список + // позиций вместо него значило бы показать одни поправки. + var composition = await dynamicResolver.ResolveAsync(group, cancellationToken); var info = await resolver.ResolveAsync( - group.Items.Select(i => (i.ElementKind, i.ElementId)), + composition.Select(e => (e.Kind, e.Id)), cancellationToken ); - var items = group - .Items.OrderBy(i => i.Position) - .Select(i => + // Идентификатор позиции есть только у ручных поправок: найденному правилом строки в таблице + // не отвечает, и вес с порядком ему не правят — у него их нет. + var itemIds = group + .Items.Where(i => i.Role != GroupItemRole.Excluded) + .ToDictionary(i => (i.ElementKind, i.ElementId), i => i.Id); + + var items = composition + .Select(element => { - info.TryGetValue((i.ElementKind, i.ElementId), out var element); + info.TryGetValue((element.Kind, element.Id), out var resolved); return new GroupItemDto( - i.Id, - i.ElementKind, - i.ElementId, + itemIds.TryGetValue((element.Kind, element.Id), out var itemId) ? itemId : null, + element.Kind, + element.Id, // Элемент мог исчезнуть из библиотеки между чисткой и чтением — показываем прочерк, // а не роняем весь экран группы. - element?.Name + resolved?.Name ?? "—", - i.Weight, - i.Position, - element?.UnitCount ?? 0, - element?.ShowKind, - element?.Audience, - element?.Year, - element?.PosterImageId + element.Weight, + element.Position, + resolved?.UnitCount ?? 0, + resolved?.ShowKind, + resolved?.Audience, + resolved?.Year, + resolved?.PosterImageId, + element.Pinned ); }) .ToList(); + var excluded = await ExcludedAsync(group, cancellationToken); + return Result.Success( new GroupDto( group.Id, group.Name, group.Description, + group.Mode, GroupFilter.FromJson(group.FilterJson), - group.ItemCount, - group.UnitCount, - group.TotalDuration.TotalSeconds, + items.Count, + items.Sum(i => i.UnitCount), + // Цифры карточки считаем по свежему составу, а не по кэшу: кэш пишут команды, + // запросу состояние менять нельзя, а показывать устаревшее рядом с актуальным + // списком — вводить в заблуждение. + composition + .Select(e => + info.TryGetValue((e.Kind, e.Id), out var r) + ? r.TotalDuration + : TimeSpan.Zero + ) + .Aggregate(TimeSpan.Zero, (sum, d) => sum + d) + .TotalSeconds, group.StatsComputedAt, - items + items, + excluded ) ); } + + /// Исключённое показываем отдельным списком: иначе непонятно, почему шоу нет в составе. + private async Task> ExcludedAsync( + Domain.Programming.Group group, + CancellationToken cancellationToken + ) + { + var excluded = group + .Items.Where(i => i.Role == GroupItemRole.Excluded) + .OrderBy(i => i.Position) + .ToList(); + if (excluded.Count == 0) + return []; + + var info = await resolver.ResolveAsync( + excluded.Select(i => (i.ElementKind, i.ElementId)), + cancellationToken + ); + + return + [ + .. excluded.Select(i => + { + info.TryGetValue((i.ElementKind, i.ElementId), out var element); + return new GroupExcludedDto(i.ElementKind, i.ElementId, element?.Name ?? "—"); + }), + ]; + } } diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupDtos.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupDtos.cs index 9b63a2d..a496ea5 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/GroupDtos.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/GroupDtos.cs @@ -12,16 +12,22 @@ public sealed record GroupSummaryDto( int UnitCount, double TotalDurationSeconds, bool HasFilter, + GroupMode Mode, DateTimeOffset? StatsComputedAt, DateTimeOffset CreatedAt ); /// -/// Позиция группы с данными элемента. — сколько единиц воспроизведения +/// Позиция состава с данными элемента. — сколько единиц воспроизведения /// она даёт: у фильма одна, у сериала и коллекции больше. /// +/// +/// Идентификатор строки в таблице позиций; null — элемент найден правилом динамической группы, +/// своей строки у него нет (а значит, нет и веса с порядком, которые можно править). +/// +/// Закреплён руками в динамической группе — правило его не выбрасывает. public sealed record GroupItemDto( - Guid Id, + Guid? Id, GroupElementKind ElementKind, Guid ElementId, string ElementName, @@ -31,19 +37,29 @@ public sealed record GroupItemDto( ShowKind? ShowKind, ShowAudience? Audience, int? Year, - Guid? PosterImageId + Guid? PosterImageId, + bool Pinned +); + +/// Элемент, исключённый из динамической группы вручную. +public sealed record GroupExcludedDto( + GroupElementKind ElementKind, + Guid ElementId, + string ElementName ); public sealed record GroupDto( Guid Id, string Name, string? Description, + GroupMode Mode, GroupFilter? Filter, int ItemCount, int UnitCount, double TotalDurationSeconds, DateTimeOffset? StatsComputedAt, - IReadOnlyList Items + IReadOnlyList Items, + IReadOnlyList Excluded ); /// Кандидат, найденный правилом набора: ещё не в группе, но подходит под фильтр. diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs index 4a37810..f248158 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs @@ -26,6 +26,11 @@ public static class GroupErrors "У группы не задано правило набора." ); + public static readonly Error ExclusionsForDynamicOnly = Error.Validation( + "Groups.ExclusionsForDynamicOnly", + "Исключения есть только у динамической группы — в статической состав правится списком." + ); + public static readonly Error SuggestionNotFound = Error.NotFound( "Groups.SuggestionNotFound", "Предложение устарело — библиотека изменилась. Обновите список предложений." diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupFilterMatcher.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupFilterMatcher.cs new file mode 100644 index 0000000..e3206b9 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/GroupFilterMatcher.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Groups; + +/// +/// Применяет правило набора к библиотеке. Одно место на всех: по нему ищет кандидатов подбор в +/// карточке группы и по нему же считается состав динамической группы — расхождение между «что +/// показал подбор» и «что ушло в эфир» было бы неотлаживаемым. +/// +/// Длительность единицы правило проверяет уже по развёрнутым данным +/// (), поэтому этот шаг вынесен в +/// и применяется вызывающей стороной после разворота. +/// +public sealed class GroupFilterMatcher(IAppDbContext dbContext) +{ + /// Элементы (шоу и коллекции), подходящие под правило, — без проверки длительности. + public async Task> MatchAsync( + GroupFilter filter, + CancellationToken cancellationToken + ) + { + var wantsShows = + filter.ElementKinds is null or { Count: 0 } + || filter.ElementKinds.Contains(GroupElementKind.Show); + var wantsCollections = + filter.ElementKinds is null or { Count: 0 } + || filter.ElementKinds.Contains(GroupElementKind.Collection); + + var showIds = await MatchShowIdsAsync(filter, cancellationToken); + + var elements = new List<(GroupElementKind, Guid)>(); + if (wantsShows) + elements.AddRange(showIds.Select(id => (GroupElementKind.Show, id))); + + if (wantsCollections) + { + // Коллекция подходит, только если подходят все её части: иначе во «взрослую ночь» через + // франшизу просочилось бы детское, а в «боевики» — комедия из той же серии фильмов. + var collections = await dbContext + .Collections.AsNoTracking() + .Select(c => new { c.Id, ShowIds = c.Items.Select(i => i.ShowId).ToList() }) + .ToListAsync(cancellationToken); + + var matching = showIds.ToHashSet(); + elements.AddRange( + collections + .Where(c => c.ShowIds.Count > 0 && c.ShowIds.All(matching.Contains)) + .Select(c => (GroupElementKind.Collection, c.Id)) + ); + } + + return elements; + } + + private async Task> MatchShowIdsAsync( + GroupFilter filter, + CancellationToken cancellationToken + ) + { + var shows = dbContext.Shows.AsNoTracking(); + + if (filter.ShowKinds is { Count: > 0 } kinds) + shows = shows.Where(s => kinds.Contains(s.Kind)); + + // Рейтинги упорядочены по возрастанию строгости, поэтому «не строже» — обычное сравнение. + // Шоу без рейтинга остаются в выборке — ровно как в планировщике (ElementSelector): источники + // проставляют рейтинг далеко не всему, и молчаливое выбрасывание непроставленного оставило бы + // от фильтра «мультики не строже PG» горстку голливудского. + if (filter.MaxAudience is { } maxAudience) + shows = shows.Where(s => s.Audience == null || s.Audience <= maxAudience); + + if (filter.YearMin is { } yearMin) + shows = shows.Where(s => s.Year != null && s.Year >= yearMin); + if (filter.YearMax is { } yearMax) + shows = shows.Where(s => s.Year != null && s.Year <= yearMax); + + // Жанры — «любой из»: шоу редко подходит под все перечисленные сразу. + if (filter.GenreIds is { Count: > 0 } genreIds) + shows = shows.Where(s => s.Genres.Any(g => genreIds.Contains(g.GenreId))); + + return await shows.Select(s => s.Id).ToListAsync(cancellationToken); + } + + /// + /// Длительность проверяется по средней единице, а не по сумме: у сериала в фильтре «серии + /// по 20–25 минут» осмысленна именно длина серии. Элементы без готовых ассетов длительности + /// не имеют — их этот фильтр не отбрасывает, иначе из группы выпадало бы всё, что ещё + /// обрабатывается. + /// + public static bool MatchesUnitDuration(GroupFilter filter, GroupElementInfo element) + { + if (filter.UnitMinutesMin is null && filter.UnitMinutesMax is null) + return true; + if (element.UnitCount == 0 || element.TotalDuration <= TimeSpan.Zero) + return true; + + var averageMinutes = element.TotalDuration.TotalMinutes / element.UnitCount; + if (filter.UnitMinutesMin is { } min && averageMinutes < min) + return false; + if (filter.UnitMinutesMax is { } max && averageMinutes > max) + return false; + return true; + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupStatsService.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupStatsService.cs index e900068..cde70ed 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/GroupStatsService.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/GroupStatsService.cs @@ -8,26 +8,33 @@ namespace TeleWave.Application.Programming.Groups; /// Пересчёт кэша статистики группы. Вызывается при каждой правке состава: цифры «342 позиции · /// 118 ч» видны прямо в инспекторе слота, и устаревшая статистика там хуже, чем её отсутствие. /// -public sealed class GroupStatsService(IAppDbContext dbContext, GroupElementResolver resolver) +public sealed class GroupStatsService( + IAppDbContext dbContext, + GroupElementResolver resolver, + DynamicGroupResolver dynamicResolver +) { public async Task RecomputeAsync(Group group, CancellationToken cancellationToken) { + // Считаем по вычисленному составу, а не по списку позиций: у динамической группы список — + // это только поправки, и «342 позиции» из него получились бы неправдой. + var composition = await dynamicResolver.ResolveAsync(group, cancellationToken); var info = await resolver.ResolveAsync( - group.Items.Select(i => (i.ElementKind, i.ElementId)), + composition.Select(e => (e.Kind, e.Id)), cancellationToken ); var unitCount = 0; var totalDuration = TimeSpan.Zero; - foreach (var item in group.Items) + foreach (var element in composition) { - if (!info.TryGetValue((item.ElementKind, item.ElementId), out var element)) + if (!info.TryGetValue((element.Kind, element.Id), out var resolved)) continue; - unitCount += element.UnitCount; - totalDuration += element.TotalDuration; + unitCount += resolved.UnitCount; + totalDuration += resolved.TotalDuration; } - group.UpdateStats(group.Items.Count, unitCount, totalDuration, DateTimeOffset.UtcNow); + group.UpdateStats(composition.Count, unitCount, totalDuration, DateTimeOffset.UtcNow); } /// Пересчитать по идентификатору, загрузив состав. Возвращает false, если группы нет. diff --git a/backend/src/TeleWave.Application/Programming/Groups/ListGroups/ListGroupsQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/ListGroups/ListGroupsQueryHandler.cs index 5fb47f8..3001941 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/ListGroups/ListGroupsQueryHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/ListGroups/ListGroupsQueryHandler.cs @@ -12,7 +12,10 @@ public sealed class ListGroupsQueryHandler(IAppDbContext dbContext) CancellationToken cancellationToken ) { - // Список читает кэш статистики, не пересчитывая его: пересчёт идёт при правке состава. + // Список читает кэш статистики, не пересчитывая его: пересчёт идёт при правке состава и при + // открытии карточки. У динамической группы кэш поэтому может отставать от библиотеки — + // рядом с цифрами показывается время расчёта (StatsComputedAt), и это честнее, чем гонять + // правила всех групп на каждый показ списка. return await dbContext .Groups.AsNoTracking() .OrderBy(g => g.Name) @@ -24,6 +27,7 @@ public sealed class ListGroupsQueryHandler(IAppDbContext dbContext) g.UnitCount, g.TotalDuration.TotalSeconds, g.FilterJson != null, + g.Mode, g.StatsComputedAt, g.CreatedAt )) diff --git a/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommand.cs b/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommand.cs index 3ec34c9..0808247 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommand.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommand.cs @@ -1,13 +1,17 @@ using LiteCqrs; using TeleWave.Application.Common.Models; +using TeleWave.Domain.Programming; namespace TeleWave.Application.Programming.Groups.UpdateGroup; -/// Имя, описание и правило набора. = null снимает правило; -/// состав группы при этом не меняется. +/// +/// Имя, описание, правило набора и режим. = null снимает правило; +/// состав группы при этом не меняется. = null оставляет режим как есть. +/// public sealed record UpdateGroupCommand( Guid GroupId, string Name, string? Description, - GroupFilter? Filter + GroupFilter? Filter, + GroupMode? Mode = null ) : ICommand; diff --git a/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommandHandler.cs index 27708fa..2f43630 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommandHandler.cs @@ -2,26 +2,57 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; +using TeleWave.Domain.Programming; namespace TeleWave.Application.Programming.Groups.UpdateGroup; -public sealed class UpdateGroupCommandHandler(IAppDbContext dbContext) - : ICommandHandler +public sealed class UpdateGroupCommandHandler( + IAppDbContext dbContext, + DynamicGroupResolver dynamicResolver, + GroupStatsService stats +) : ICommandHandler { public async Task Handle( UpdateGroupCommand command, CancellationToken cancellationToken ) { - var group = await dbContext.Groups.FirstOrDefaultAsync( - g => g.Id == command.GroupId, - cancellationToken - ); + var group = await dbContext + .Groups.Include(g => g.Items) + .FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken); if (group is null) return Result.Failure(GroupErrors.NotFound); group.Rename(command.Name, command.Description); group.SetFilter(command.Filter?.ToJson()); + + if (command.Mode is { } mode && mode != group.Mode) + await SwitchModeAsync(group, mode, cancellationToken); + + await stats.RecomputeAsync(group, cancellationToken); return Result.Success(); } + + /// + /// Переход в статический режим материализует вычисленный состав: правило домен не понимает, + /// а группа не должна опустеть от смены режима — на неё уже ссылаются слоты. Обратный переход + /// домен делает сам (список становится закреплённым). + /// + private async Task SwitchModeAsync( + Domain.Programming.Group group, + GroupMode mode, + CancellationToken cancellationToken + ) + { + if (mode == GroupMode.Dynamic) + { + group.SetMode(mode); + return; + } + + var composition = await dynamicResolver.ResolveAsync(group, cancellationToken); + group.SetMode(mode); + foreach (var element in composition) + group.AddElement(element.Kind, element.Id); + } } diff --git a/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs b/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs index 13860cf..0b0ac8b 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Programming.Groups; using TeleWave.Domain.Broadcast; using TeleWave.Domain.Library; using TeleWave.Domain.Media; @@ -16,7 +17,7 @@ namespace TeleWave.Application.Programming.Planning; /// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё /// обрабатывается, значит получить дыру в раздаче. /// -public sealed class GroupExpander(IAppDbContext dbContext) +public sealed class GroupExpander(IAppDbContext dbContext, DynamicGroupResolver dynamicResolver) { /// С какого момента нужна история показов для потолка повторов; /// null — история не нужна и не загружается. @@ -37,10 +38,17 @@ public sealed class GroupExpander(IAppDbContext dbContext) .Where(g => groupIds.Contains(g.Id)) .ToListAsync(cancellationToken); - var collectionIds = groups - .SelectMany(g => g.Items) - .Where(i => i.ElementKind == GroupElementKind.Collection) - .Select(i => i.ElementId) + // Состав считается здесь, перед самым разворотом: у динамической группы он живёт не в + // списке позиций, а в правиле, и должен быть свежим на момент прогона. + var composition = new Dictionary>(); + foreach (var group in groups) + composition[group.Id] = await dynamicResolver.ResolveAsync(group, cancellationToken); + + var elements = composition.Values.SelectMany(c => c).ToList(); + + var collectionIds = elements + .Where(e => e.Kind == GroupElementKind.Collection) + .Select(e => e.Id) .Distinct() .ToList(); @@ -51,10 +59,9 @@ public sealed class GroupExpander(IAppDbContext dbContext) .Select(i => new { i.CollectionId, i.ShowId }) .ToListAsync(cancellationToken); - var showIds = groups - .SelectMany(g => g.Items) - .Where(i => i.ElementKind == GroupElementKind.Show) - .Select(i => i.ElementId) + var showIds = elements + .Where(e => e.Kind == GroupElementKind.Show) + .Select(e => e.Id) .Concat(collectionParts.Select(p => p.ShowId)) .Distinct() .ToList(); @@ -96,15 +103,17 @@ public sealed class GroupExpander(IAppDbContext dbContext) collectionParts.ToLookup(p => p.CollectionId, p => p.ShowId) ); - foreach (var group in groups) - result[group.Id] = group - .Items.OrderBy(i => i.Position) - .Select(item => - item.ElementKind == GroupElementKind.Show - ? data.ShowElement(item) - : data.CollectionElement(item) - ) - .ToList(); + foreach (var (groupId, resolved) in composition) + result[groupId] = + [ + .. resolved + .OrderBy(e => e.Position) + .Select(element => + element.Kind == GroupElementKind.Show + ? data.ShowElement(element) + : data.CollectionElement(element) + ), + ]; return result; } @@ -125,16 +134,16 @@ public sealed class GroupExpander(IAppDbContext dbContext) ILookup CollectionParts ) { - public PlanningElement ShowElement(GroupItem item) => + public PlanningElement ShowElement(ResolvedElement element) => new( - item.ElementKind, - item.ElementId, - item.Weight, - item.Position, - UnitsOf(item.ElementId), - LastPlayedOf(item.ElementId), - AudienceOf(item.ElementId), - RecentPlaysOf(item.ElementId) + element.Kind, + element.Id, + element.Weight, + element.Position, + UnitsOf(element.Id), + LastPlayedOf(element.Id), + AudienceOf(element.Id), + RecentPlaysOf(element.Id) ); /// @@ -142,14 +151,14 @@ public sealed class GroupExpander(IAppDbContext dbContext) /// части (показ любой означает, что франшиза недавно была в эфире); рейтинг — строжайший /// (франшиза идёт целиком, и одна взрослая часть делает взрослой всю). /// - public PlanningElement CollectionElement(GroupItem item) + public PlanningElement CollectionElement(ResolvedElement element) { - var parts = CollectionParts[item.ElementId].ToList(); + var parts = CollectionParts[element.Id].ToList(); return new PlanningElement( - item.ElementKind, - item.ElementId, - item.Weight, - item.Position, + element.Kind, + element.Id, + element.Weight, + element.Position, parts.SelectMany(UnitsOf).ToList(), parts.Select(LastPlayedOf).Where(p => p is not null).DefaultIfEmpty(null).Max(), parts.Select(AudienceOf).Where(a => a is not null).DefaultIfEmpty(null).Max(), diff --git a/backend/src/TeleWave.Domain/Programming/Group.cs b/backend/src/TeleWave.Domain/Programming/Group.cs index a486263..ee3bd44 100644 --- a/backend/src/TeleWave.Domain/Programming/Group.cs +++ b/backend/src/TeleWave.Domain/Programming/Group.cs @@ -4,10 +4,20 @@ namespace TeleWave.Domain.Programming; /// Группа контента — «что может попасть в эфир». Слот планировщика ссылается на группу, стратегия /// выбирает из неё элемент. Группы общие для всех каналов и переиспользуются. /// -/// Состав — всегда явный список (), а — лишь правило -/// быстрого набора: «применить фильтр» добавляет найденное в список, дальше состав правится руками. -/// Живой запрос при генерации был бы проще в коде, но тогда добавление одного шоу в библиотеку -/// перетасовывало бы всё будущее расписание всех каналов. +/// Откуда берётся состав, решает : +/// +/// • — состав это явный список (), а +/// лишь помогает его наполнить: «применить фильтр» добавляет найденное +/// в список, дальше он правится руками. +/// +/// • — состав вычисляет правило в момент обращения, а список хранит +/// только ручные поправки к нему: закреплённое () и исключённое +/// (). Само правило домен по-прежнему не интерпретирует — +/// вычисление живёт в Application. +/// +/// Уже материализованное расписание в обоих режимах не переписывается: изменение состава проявится +/// на следующем прогоне для ещё не сгенерированных суток. Иначе добавление одного шоу в библиотеку +/// перетасовывало бы будущий эфир всех каналов. /// public class Group { @@ -20,6 +30,9 @@ public class Group /// Правило набора (JSON) или null. Домен его не интерпретирует — схема живёт в Application. public string? FilterJson { get; private set; } + /// Откуда берётся состав: явный список либо вычисление правилом. + public GroupMode Mode { get; private set; } + // ── Кэш статистики: считается при правке состава и фоново, нужен UI («342 позиции · 118 ч») ── /// Число позиций в группе. @@ -59,6 +72,33 @@ public class Group public void SetFilter(string? filterJson) => FilterJson = string.IsNullOrWhiteSpace(filterJson) ? null : filterJson; + /// + /// Сменить режим. Переключения не должны менять того, что уже идёт в эфире, поэтому состав + /// переносится, а не теряется: при переходе в динамический список позиций становится + /// закреплённым (правило добавит к нему остальное), при обратном переходе закреплённое + /// становится обычными позициями, а исключения снимаются — исключать больше нечего. + /// + /// Вычисленный правилом состав при переходе в статический режим сюда не переносится: домен + /// правило не интерпретирует. Материализует его вызывающая сторона до смены режима. + /// + public void SetMode(GroupMode mode) + { + if (Mode == mode) + return; + + Mode = mode; + if (mode == GroupMode.Dynamic) + { + foreach (var item in _items) + item.SetRole(GroupItemRole.Pinned); + return; + } + + _items.RemoveAll(i => i.Role == GroupItemRole.Excluded); + foreach (var item in _items) + item.SetRole(GroupItemRole.Member); + } + /// Записать пересчитанную статистику. public void UpdateStats(int itemCount, int unitCount, TimeSpan totalDuration, DateTimeOffset at) { @@ -71,18 +111,55 @@ public class Group public bool Contains(GroupElementKind kind, Guid elementId) => _items.Any(i => i.ElementKind == kind && i.ElementId == elementId); - /// Добавляет элемент в конец. Повторное добавление игнорируется — возвращает null. - public GroupItem? AddElement(GroupElementKind kind, Guid elementId) + /// + /// Добавляет элемент в конец. Повторное добавление игнорируется — возвращает null. Роль по + /// умолчанию зависит от режима: в динамической группе ручное добавление означает «закрепить», + /// иначе правило вымело бы позицию на первом же обращении. + /// + public GroupItem? AddElement(GroupElementKind kind, Guid elementId, GroupItemRole? role = null) { if (Contains(kind, elementId)) return null; var nextPosition = _items.Count == 0 ? 0 : _items.Max(i => i.Position) + 1; - var item = GroupItem.Create(Id, kind, elementId, nextPosition); + var item = GroupItem.Create(Id, kind, elementId, nextPosition, role ?? DefaultRole); _items.Add(item); return item; } + private GroupItemRole DefaultRole => + Mode == GroupMode.Dynamic ? GroupItemRole.Pinned : GroupItemRole.Member; + + /// + /// Исключить элемент из динамической группы. Закрепление, если оно было, снимается: держать + /// на один элемент обе противоположные поправки нельзя. Возвращает false, если группа + /// статическая — там состав правится списком, а исключать из списка нечего. + /// + public bool Exclude(GroupElementKind kind, Guid elementId) + { + if (Mode != GroupMode.Dynamic) + return false; + + var existing = _items.FirstOrDefault(i => + i.ElementKind == kind && i.ElementId == elementId + ); + if (existing is not null) + { + existing.SetRole(GroupItemRole.Excluded); + return true; + } + + var nextPosition = _items.Count == 0 ? 0 : _items.Max(i => i.Position) + 1; + _items.Add(GroupItem.Create(Id, kind, elementId, nextPosition, GroupItemRole.Excluded)); + return true; + } + + /// Снять исключение — элемент вернётся в состав, если его находит правило. + public bool RemoveExclusion(GroupElementKind kind, Guid elementId) => + _items.RemoveAll(i => + i.Role == GroupItemRole.Excluded && i.ElementKind == kind && i.ElementId == elementId + ) > 0; + public bool RemoveItem(Guid itemId) { var item = _items.FirstOrDefault(i => i.Id == itemId); diff --git a/backend/src/TeleWave.Domain/Programming/GroupItem.cs b/backend/src/TeleWave.Domain/Programming/GroupItem.cs index 69c5cab..a413b08 100644 --- a/backend/src/TeleWave.Domain/Programming/GroupItem.cs +++ b/backend/src/TeleWave.Domain/Programming/GroupItem.cs @@ -13,6 +13,9 @@ public class GroupItem public GroupElementKind ElementKind { get; private set; } public Guid ElementId { get; private set; } + /// Чем эта позиция является для группы — см. . + public GroupItemRole Role { get; private set; } + /// /// Вес при случайном выборе внутри группы (по умолчанию 1). Не конфликтует с остыванием, если /// применять их в правильном порядке: сначала остывание отсекает недавно показанное, затем @@ -31,7 +34,8 @@ public class GroupItem Guid groupId, GroupElementKind elementKind, Guid elementId, - int position + int position, + GroupItemRole role = GroupItemRole.Member ) => new() { @@ -39,10 +43,13 @@ public class GroupItem GroupId = groupId, ElementKind = elementKind, ElementId = elementId, + Role = role, Weight = DefaultWeight, Position = position, }; + internal void SetRole(GroupItemRole role) => Role = role; + internal void SetPosition(int position) => Position = position; internal void SetWeight(int weight) => Weight = Math.Max(0, weight); diff --git a/backend/src/TeleWave.Domain/Programming/GroupItemRole.cs b/backend/src/TeleWave.Domain/Programming/GroupItemRole.cs new file mode 100644 index 0000000..278ea1a --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/GroupItemRole.cs @@ -0,0 +1,17 @@ +namespace TeleWave.Domain.Programming; + +/// +/// Роль позиции в группе. У статической группы все позиции — : список и есть +/// состав. У динамической состав считает правило, а позиции хранят ручные поправки к нему. +/// +public enum GroupItemRole +{ + /// Обычная позиция статической группы. + Member = 0, + + /// Всегда в составе динамической группы, даже если правило её не находит. + Pinned = 1, + + /// Никогда не в составе динамической группы, даже если правило её находит. + Excluded = 2, +} diff --git a/backend/src/TeleWave.Domain/Programming/GroupMode.cs b/backend/src/TeleWave.Domain/Programming/GroupMode.cs new file mode 100644 index 0000000..3384985 --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/GroupMode.cs @@ -0,0 +1,14 @@ +namespace TeleWave.Domain.Programming; + +/// Откуда группа берёт состав. +public enum GroupMode +{ + /// Явный список позиций. Правило набора — лишь помощник наполнения. + Static = 0, + + /// + /// Состав вычисляется правилом в момент обращения (генерация расписания, показ карточки). + /// Позиции при этом хранят только ручные поправки: закреплённое и исключённое. + /// + Dynamic = 1, +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260727013448_DynamicGroups.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260727013448_DynamicGroups.Designer.cs new file mode 100644 index 0000000..e309e9f --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260727013448_DynamicGroups.Designer.cs @@ -0,0 +1,1399 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260727013448_DynamicGroups")] + partial class DynamicGroups + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AnalogFilterStrength") + .HasColumnType("double precision"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DayStartTime") + .HasColumnType("time without time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LogoCorner") + .HasColumnType("integer"); + + b.Property("LogoImageId") + .HasColumnType("uuid"); + + b.Property("LogoOpacity") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("ShowClock") + .HasColumnType("boolean"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UtcOffsetMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique() + .HasFilter("\"Number\" IS NOT NULL"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TraceJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.HasIndex("ChannelId", "ShowId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Collections"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShowId"); + + b.HasIndex("CollectionId", "Position"); + + b.HasIndex("CollectionId", "ShowId") + .IsUnique(); + + b.ToTable("CollectionItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("Value") + .IsUnique(); + + b.ToTable("GenreAliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Audience") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.HasKey("ShowId", "GenreId"); + + b.HasIndex("GenreId"); + + b.ToTable("ShowGenres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessingDuration") + .HasColumnType("interval"); + + b.Property("ProcessingStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicabilityJson") + .HasColumnType("jsonb"); + + b.Property("IsBackground") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Priority"); + + b.ToTable("GridLayers"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FilterJson") + .HasColumnType("jsonb"); + + b.Property("ItemCount") + .HasColumnType("integer"); + + b.Property("Mode") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StatsComputedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalDuration") + .HasColumnType("interval"); + + b.Property("UnitCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ElementId") + .HasColumnType("uuid"); + + b.Property("ElementKind") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Role") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ElementKind", "ElementId"); + + b.HasIndex("GroupId", "Position"); + + b.HasIndex("GroupId", "ElementKind", "ElementId") + .IsUnique(); + + b.ToTable("GroupItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AmountMode") + .HasColumnType("integer"); + + b.Property("AmountValue") + .HasColumnType("integer"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("ConditionsJson") + .HasColumnType("jsonb"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("JunctionTemplateId") + .HasColumnType("uuid"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("JunctionTemplateId", "Position"); + + b.ToTable("JunctionElements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("JunctionTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppliedRevision") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultJunctionId") + .HasColumnType("uuid"); + + b.Property("FallbackGroupId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("RulesJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("ScheduleTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("Daypart") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAnchor") + .HasColumnType("boolean"); + + b.Property("JunctionAfterId") + .HasColumnType("uuid"); + + b.Property("JunctionBetweenId") + .HasColumnType("uuid"); + + b.Property("LayerId") + .HasColumnType("uuid"); + + b.Property("MaxDriftMinutes") + .HasColumnType("integer"); + + b.Property("OverflowPolicy") + .HasColumnType("integer"); + + b.Property("RepeatSourceJson") + .HasColumnType("jsonb"); + + b.Property("SlotKind") + .HasColumnType("integer"); + + b.Property("SnapToMinutes") + .HasColumnType("integer"); + + b.Property("StrategyJson") + .HasColumnType("jsonb"); + + b.Property("TargetDurationMinutes") + .HasColumnType("integer"); + + b.Property("TargetStart") + .HasColumnType("time without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Weekday") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("LayerId", "TargetStart"); + + b.ToTable("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("CurrentElementId") + .HasColumnType("uuid"); + + b.Property("CurrentElementKind") + .HasColumnType("integer"); + + b.Property("NextUnitIndex") + .HasColumnType("integer"); + + b.HasKey("SlotId"); + + b.ToTable("SlotStates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.HasOne("TeleWave.Domain.Library.Collection", null) + .WithMany("Items") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany() + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.HasOne("TeleWave.Domain.Programming.ScheduleTemplate", null) + .WithMany("Layers") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.JunctionTemplate", null) + .WithMany("Elements") + .HasForeignKey("JunctionTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.GridLayer", null) + .WithMany("Slots") + .HasForeignKey("LayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.HasOne("TeleWave.Domain.Programming.Slot", null) + .WithOne() + .HasForeignKey("TeleWave.Domain.Programming.SlotState", "SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("BumperTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Navigation("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Navigation("Elements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Navigation("Layers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260727013448_DynamicGroups.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260727013448_DynamicGroups.cs new file mode 100644 index 0000000..cedff60 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260727013448_DynamicGroups.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class DynamicGroups : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Mode", + table: "Groups", + type: "integer", + nullable: false, + defaultValue: 0 + ); + + migrationBuilder.AddColumn( + name: "Role", + table: "GroupItems", + type: "integer", + nullable: false, + defaultValue: 0 + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "Mode", table: "Groups"); + + migrationBuilder.DropColumn(name: "Role", table: "GroupItems"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index dbbdee8..ba6e6df 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -803,6 +803,11 @@ namespace TeleWave.Infrastructure.Migrations b.Property("ItemCount") .HasColumnType("integer"); + b.Property("Mode") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + b.Property("Name") .IsRequired() .HasMaxLength(256) @@ -839,6 +844,11 @@ namespace TeleWave.Infrastructure.Migrations b.Property("Position") .HasColumnType("integer"); + b.Property("Role") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + b.Property("Weight") .HasColumnType("integer"); diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/GroupConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/GroupConfiguration.cs index 21f5953..aecf6bb 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/GroupConfiguration.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/GroupConfiguration.cs @@ -15,6 +15,8 @@ public class GroupConfiguration : IEntityTypeConfiguration // Правило набора хранится как jsonb: схему знает Application, а домен передаёт строку как есть. builder.Property(x => x.FilterJson).HasColumnType("jsonb"); + builder.Property(x => x.Mode).HasDefaultValue(GroupMode.Static); + builder .HasMany(x => x.Items) .WithOne() @@ -42,5 +44,9 @@ public class GroupItemConfiguration : IEntityTypeConfiguration // По этому индексу чистятся позиции при удалении шоу/коллекции: внешнего ключа на // полиморфную ссылку нет, удаление идёт командой. builder.HasIndex(x => new { x.ElementKind, x.ElementId }); + + // Роль читается при каждом развороте динамической группы — отдельным индексом не выделяем, + // выборка всегда идёт по группе целиком. + builder.Property(x => x.Role).HasDefaultValue(GroupItemRole.Member); } } diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs index 679dacc..6b70cf1 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs @@ -22,7 +22,7 @@ public class QueryHandlersTests private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) => - new(db, new GroupStatsService(db, new GroupElementResolver(db))); + new(db, GroupServices.Stats(db)); [Fact] public async Task GetChannel_UnknownId_ReturnsNotFound() diff --git a/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs b/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs index 27339e0..740d501 100644 --- a/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs @@ -25,7 +25,7 @@ public class DeleteGuardsTests private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) => - new(db, new GroupStatsService(db, new GroupElementResolver(db))); + new(db, GroupServices.Stats(db)); [Fact] public async Task DeleteShow_InFutureSchedule_Fails() diff --git a/backend/tests/TeleWave.Application.Tests/Programming/DynamicGroupsTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/DynamicGroupsTests.cs new file mode 100644 index 0000000..4421b7f --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/DynamicGroupsTests.cs @@ -0,0 +1,237 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Programming.Groups; +using TeleWave.Application.Programming.Groups.ExcludeGroupElement; +using TeleWave.Application.Programming.Groups.UpdateGroup; +using TeleWave.Application.Programming.Planning; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using TeleWave.Domain.Programming; +using Xunit; + +namespace TeleWave.Application.Tests.Programming; + +/// +/// Динамическая группа: состав считает правило, а список позиций хранит только ручные поправки. +/// Проверяем именно то, что уходит в эфир, — иначе расхождение между показанным и сыгранным. +/// +public class DynamicGroupsTests +{ + [Fact] + public async Task Composition_IsRuleResult_PinnedFirstThenAlphabetical() + { + var fixture = new TestDb(); + var genre = Genre.Create("Мультфильм", "animation"); + var beta = WithGenre(Show.Create("Бета", ShowKind.Series), genre); + var alpha = WithGenre(Show.Create("Альфа", ShowKind.Series), genre); + // Закреплённое правилу не подходит, но остаётся в составе — и идёт первым. + var pinned = Show.Create("Закреплённое", ShowKind.Single); + + var group = Group.Create("Мультфильмы"); + group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson()); + group.AddElement(GroupElementKind.Show, pinned.Id); + group.SetMode(GroupMode.Dynamic); + + await using (var seed = fixture.New()) + { + seed.Genres.Add(genre); + seed.Shows.AddRange(alpha, beta, pinned); + seed.Groups.Add(group); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var composition = await ResolveAsync(fixture, group.Id); + + Assert.Equal([pinned.Id, alpha.Id, beta.Id], composition.Select(e => e.Id)); + Assert.True(composition[0].Pinned); + Assert.All(composition.Skip(1), e => Assert.False(e.Pinned)); + } + + [Fact] + public async Task Excluded_DropsOutOfComposition() + { + var fixture = new TestDb(); + var genre = Genre.Create("Мультфильм", "animation"); + var keep = WithGenre(Show.Create("Остаётся", ShowKind.Series), genre); + var drop = WithGenre(Show.Create("Выкинуть", ShowKind.Series), genre); + + var group = Group.Create("Мультфильмы"); + group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson()); + group.SetMode(GroupMode.Dynamic); + + await using (var seed = fixture.New()) + { + seed.Genres.Add(genre); + seed.Shows.AddRange(keep, drop); + seed.Groups.Add(group); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using (var db = fixture.New()) + { + var result = await new ExcludeGroupElementCommandHandler( + db, + GroupServices.Stats(db) + ).Handle( + new ExcludeGroupElementCommand(group.Id, GroupElementKind.Show, drop.Id, true), + CancellationToken.None + ); + Assert.True(result.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + } + + var composition = await ResolveAsync(fixture, group.Id); + Assert.Equal([keep.Id], composition.Select(e => e.Id)); + + // Снятие исключения возвращает элемент правилу. + await using (var db = fixture.New()) + { + await new ExcludeGroupElementCommandHandler(db, GroupServices.Stats(db)).Handle( + new ExcludeGroupElementCommand(group.Id, GroupElementKind.Show, drop.Id, false), + CancellationToken.None + ); + await db.SaveChangesAsync(CancellationToken.None); + } + + Assert.Equal(2, (await ResolveAsync(fixture, group.Id)).Count); + } + + [Fact] + public async Task SwitchToDynamic_KeepsExistingItemsAsPinned() + { + // Смена режима не должна вымывать из эфира то, что уже стояло в слотах. + var fixture = new TestDb(); + var show = Show.Create("A", ShowKind.Series); + var group = Group.Create("Группа"); + group.AddElement(GroupElementKind.Show, show.Id); + + await using (var seed = fixture.New()) + { + seed.Shows.Add(show); + seed.Groups.Add(group); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await UpdateModeAsync(fixture, group.Id, GroupMode.Dynamic); + + await using var check = fixture.New(); + var stored = check.Groups.Include(g => g.Items).Single(); + Assert.Equal(GroupMode.Dynamic, stored.Mode); + Assert.Equal(GroupItemRole.Pinned, stored.Items.Single().Role); + } + + [Fact] + public async Task SwitchToStatic_MaterializesComputedComposition() + { + var fixture = new TestDb(); + var genre = Genre.Create("Мультфильм", "animation"); + var first = WithGenre(Show.Create("A", ShowKind.Series), genre); + var second = WithGenre(Show.Create("B", ShowKind.Series), genre); + + var group = Group.Create("Мультфильмы"); + group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson()); + group.SetMode(GroupMode.Dynamic); + + await using (var seed = fixture.New()) + { + seed.Genres.Add(genre); + seed.Shows.AddRange(first, second); + seed.Groups.Add(group); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await UpdateModeAsync(fixture, group.Id, GroupMode.Static); + + await using var check = fixture.New(); + var stored = check.Groups.Include(g => g.Items).Single(); + Assert.Equal(GroupMode.Static, stored.Mode); + Assert.Equal(2, stored.Items.Count); + Assert.All(stored.Items, item => Assert.Equal(GroupItemRole.Member, item.Role)); + } + + [Fact] + public async Task Planner_ExpandsDynamicGroupByRule() + { + // Главное свойство: правило применяется на самой генерации, а не когда-то до неё. + var fixture = new TestDb(); + var genre = Genre.Create("Мультфильм", "animation"); + var asset = MediaAsset.Register("a.mkv", ".mkv", MediaSource.Upload); + asset.MarkProcessing(); + asset.MarkReady( + new MediaReadyInfo( + TimeSpan.FromMinutes(22), + 2, + 660, + 1920, + 1080, + "h264", + "aac", + "assets/a" + ) + ); + var show = WithGenre(Show.Create("A", ShowKind.Series), genre); + show.AddEpisode(asset.Id); + + var group = Group.Create("Мультфильмы"); + group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson()); + group.SetMode(GroupMode.Dynamic); + + await using (var seed = fixture.New()) + { + seed.Genres.Add(genre); + seed.MediaAssets.Add(asset); + seed.Shows.Add(show); + seed.Groups.Add(group); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var expanded = await new GroupExpander(db, GroupServices.Dynamic(db)).ExpandAsync( + [group.Id], + Guid.NewGuid(), + CancellationToken.None + ); + + var element = Assert.Single(expanded[group.Id]); + Assert.Equal(show.Id, element.ElementId); + Assert.Single(element.Units); + } + + private static Show WithGenre(Show show, Genre genre) + { + show.SetGenres([genre.Id]); + return show; + } + + private static async Task> ResolveAsync( + TestDb fixture, + Guid groupId + ) + { + await using var db = fixture.New(); + var group = await db.Groups.Include(g => g.Items).FirstAsync(g => g.Id == groupId); + return await GroupServices.Dynamic(db).ResolveAsync(group, CancellationToken.None); + } + + private static async Task UpdateModeAsync(TestDb fixture, Guid groupId, GroupMode mode) + { + await using var db = fixture.New(); + var group = await db.Groups.FirstAsync(g => g.Id == groupId); + var result = await new UpdateGroupCommandHandler( + db, + GroupServices.Dynamic(db), + GroupServices.Stats(db) + ).Handle( + new UpdateGroupCommand( + groupId, + group.Name, + group.Description, + GroupFilter.FromJson(group.FilterJson), + mode + ), + CancellationToken.None + ); + Assert.True(result.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GroupSuggestionsTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GroupSuggestionsTests.cs index 3051bc7..6324f66 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/GroupSuggestionsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/GroupSuggestionsTests.cs @@ -129,7 +129,7 @@ public class GroupSuggestionsTests var handler = new CreateGroupFromSuggestionCommandHandler( db, Builder(db), - new GroupStatsService(db, new GroupElementResolver(db)) + GroupServices.Stats(db) ); var created = await handler.Handle( @@ -168,7 +168,7 @@ public class GroupSuggestionsTests var result = await new CreateGroupFromSuggestionCommandHandler( db, Builder(db), - new GroupStatsService(db, new GroupElementResolver(db)) + GroupServices.Stats(db) ).Handle(new CreateGroupFromSuggestionCommand("genre:нет-такого"), CancellationToken.None); Assert.False(result.IsSuccess); diff --git a/backend/tests/TeleWave.Application.Tests/Support/GroupServices.cs b/backend/tests/TeleWave.Application.Tests/Support/GroupServices.cs new file mode 100644 index 0000000..9f10191 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Support/GroupServices.cs @@ -0,0 +1,17 @@ +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Programming.Groups; + +namespace TeleWave.Application.Tests.Support; + +/// +/// Сборка сервисов группы для тестов: их четыре и они цепляются друг за друга, а в тестах нужен +/// каждый раз один и тот же набор. В приложении это делает контейнер. +/// +internal static class GroupServices +{ + public static DynamicGroupResolver Dynamic(IAppDbContext db) => + new(new GroupFilterMatcher(db), new GroupElementResolver(db)); + + public static GroupStatsService Stats(IAppDbContext db) => + new(db, new GroupElementResolver(db), Dynamic(db)); +} diff --git a/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs index 3face53..5f47a62 100644 --- a/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs +++ b/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Options; using NSubstitute; using TeleWave.Application.Broadcast.Scheduling; using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Programming.Groups; using TeleWave.Application.Programming.Planning; using TeleWave.Application.Programming.Templates; using TeleWave.Application.Streaming; @@ -192,7 +193,10 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur var random = new SequenceRandom(0); return new GridScheduleGenerator( db, - new GroupExpander(db), + new GroupExpander( + db, + new DynamicGroupResolver(new GroupFilterMatcher(db), new GroupElementResolver(db)) + ), new BumperResolver(db, Substitute.For(), random), new PostCheckRunner(db), random, diff --git a/frontend/src/features/admin/groups/GroupDetail.tsx b/frontend/src/features/admin/groups/GroupDetail.tsx index a6455c5..19d454e 100644 --- a/frontend/src/features/admin/groups/GroupDetail.tsx +++ b/frontend/src/features/admin/groups/GroupDetail.tsx @@ -1,18 +1,25 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' -import { ChevronLeft, GripVertical, Search, Sparkles, Trash2 } from 'lucide-react' +import { ChevronLeft, GripVertical, Search, Sparkles, Trash2, Undo2 } from 'lucide-react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { qk } from '@/shared/api/query-keys' -import type { GroupCandidateDto, GroupFilter } from '@/shared/api/types' +import type { + GroupCandidateDto, + GroupElementKind, + GroupFilter, + GroupMode, +} 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 { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' import { toast } from '@/shared/ui/toast-store' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { addGroupElements, + excludeGroupElement, findGroupCandidates, getGroup, removeGroupItem, @@ -60,21 +67,35 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) { const { data: pending } = useQuery({ queryKey: qk.groups.pending(groupId), queryFn: () => findGroupCandidates(groupId, null), - enabled: !!group?.filter, + // Динамической группе подсказка не нужна: правило применяется само при каждом обращении. + enabled: !!group?.filter && group?.mode === 'Static', }) const pendingFresh = (pending ?? []).filter((c) => !c.alreadyInGroup) const saveMutation = useMutation({ - mutationFn: () => + mutationFn: (mode?: GroupMode) => updateGroup(groupId, { name: (name ?? group?.name ?? '').trim(), description: description ?? group?.description ?? null, filter: filter ?? null, + mode, }), onSuccess: invalidate, onError, }) + const excludeMutation = useMutation({ + mutationFn: ({ + element, + excluded, + }: Readonly<{ + element: { elementKind: GroupElementKind; elementId: string } + excluded: boolean + }>) => excludeGroupElement(groupId, element, excluded), + onSuccess: invalidate, + onError, + }) + const findMutation = useMutation({ mutationFn: () => findGroupCandidates(groupId, filter ?? EMPTY_FILTER), onSuccess: setCandidates, @@ -116,13 +137,18 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) { const dropOn = (targetItemId: string) => { if (!dragged || dragged === targetItemId) return - const order = group.items.map((i) => i.id).filter((id) => id !== dragged) + // Порядок задаётся идентификаторами позиций, поэтому найденное правилом (без своей строки) + // в перестановку не входит — его место определяет алфавит. + const order = group.items + .map((i) => i.id) + .filter((id): id is string => id !== null && id !== dragged) order.splice(order.indexOf(targetItemId), 0, dragged) setDragged(null) reorderMutation.mutate(order) } const fresh = (candidates ?? []).filter((c) => !c.alreadyInGroup) + const isDynamic = group.mode === 'Dynamic' return (
@@ -153,11 +179,35 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) { onChange={(e) => setDescription(e.target.value)} />
- + {/* Режим — первое, что нужно понять про группу: от него зависит, чем является список ниже. */} +
+ {t('admin.groups.mode.label')}: + + + {t(`admin.groups.mode.${group.mode}Hint`)} + +
+
{t('admin.groups.items')}: {group.itemCount} @@ -263,50 +313,118 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) { {showWeights ? t('admin.groups.hideAdvanced') : t('admin.groups.showAdvanced')}
-

{t('admin.groups.orderHint')}

+

+ {isDynamic ? t('admin.groups.dynamicOrderHint') : t('admin.groups.orderHint')} +

{group.items.length === 0 ? (

{t('admin.groups.empty')}

) : (
    - {group.items.map((item, index) => ( -
  • setDragged(item.id)} - onDragOver={(e) => e.preventDefault()} - onDrop={() => dropOn(item.id)} - className="flex items-center gap-2 py-2" - > - - {index + 1} - {item.elementName} - - {t(`admin.groups.elementKinds.${item.elementKind}`)} - - - {t('admin.groups.units')}: {item.unitCount} - - {showWeights && ( - { - const weight = Number(e.target.value) - if (Number.isFinite(weight) && weight !== item.weight) - weightMutation.mutate({ itemId: item.id, weight }) - }} + {group.items.map((item, index) => { + // Перетаскивать и взвешивать можно только то, у чего есть своя строка: найденному + // правилом порядок задаёт алфавит, а вес всегда единица. + const editable = item.id !== null + return ( +
  • item.id && setDragged(item.id)} + onDragOver={(e) => e.preventDefault()} + onDrop={() => item.id && dropOn(item.id)} + className="flex items-center gap-2 py-2" + > + - )} - -
  • - ))} + {index + 1} + {item.elementName} + {isDynamic && ( + + {item.pinned ? t('admin.groups.pinned') : t('admin.groups.byRule')} + + )} + + {t(`admin.groups.elementKinds.${item.elementKind}`)} + + + {t('admin.groups.units')}: {item.unitCount} + + {showWeights && editable && ( + { + const weight = Number(e.target.value) + if (item.id && Number.isFinite(weight) && weight !== item.weight) + weightMutation.mutate({ itemId: item.id, weight }) + }} + /> + )} + + + ) + })}
)} + + {/* Исключённое видно списком: иначе непонятно, почему шоу нет в составе. */} + {isDynamic && group.excluded.length > 0 && ( +
+ + {t('admin.groups.excluded')} + +
    + {group.excluded.map((element) => ( +
  • + + {element.elementName} + + +
  • + ))} +
+
+ )} diff --git a/frontend/src/features/admin/groups/GroupsPanel.tsx b/frontend/src/features/admin/groups/GroupsPanel.tsx index a7387a6..fa9b0ec 100644 --- a/frontend/src/features/admin/groups/GroupsPanel.tsx +++ b/frontend/src/features/admin/groups/GroupsPanel.tsx @@ -116,8 +116,12 @@ export function GroupsPanel() { > {group.name} - {group.hasFilter && ( - {t('admin.groups.hasFilter')} + {group.mode === 'Dynamic' ? ( + {t('admin.groups.mode.Dynamic')} + ) : ( + group.hasFilter && ( + {t('admin.groups.hasFilter')} + ) )} diff --git a/frontend/src/features/admin/groups/api.ts b/frontend/src/features/admin/groups/api.ts index 309ba2b..f24ec28 100644 --- a/frontend/src/features/admin/groups/api.ts +++ b/frontend/src/features/admin/groups/api.ts @@ -5,6 +5,7 @@ import type { GroupDto, GroupElementKind, GroupFilter, + GroupMode, GroupSuggestionDto, GroupSummaryDto, } from '@/shared/api/types' @@ -23,7 +24,13 @@ export function createGroup(body: { name: string; description?: string }) { export function updateGroup( id: string, - body: { name: string; description: string | null; filter: GroupFilter | null }, + body: { + name: string + description: string | null + filter: GroupFilter | null + /** Не передан — режим остаётся прежним. */ + mode?: GroupMode + }, ) { return apiRequest(`/admin/groups/${id}`, { method: 'PUT', body }) } @@ -63,6 +70,18 @@ export function addGroupElements( }) } +/** Исключить элемент из вычисленного состава динамической группы либо вернуть его правилу. */ +export function excludeGroupElement( + id: string, + element: { elementKind: GroupElementKind; elementId: string }, + excluded: boolean, +) { + return apiRequest(`/admin/groups/${id}/exclusions`, { + method: 'POST', + body: { ...element, excluded }, + }) +} + export function removeGroupItem(id: string, itemId: string) { return apiRequest(`/admin/groups/${id}/items/${itemId}`, { method: 'DELETE' }) } diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 8fcf6db..a71f5ea 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -248,12 +248,17 @@ export type GroupSummaryDto = { unitCount: number totalDurationSeconds: number hasFilter: boolean + mode: GroupMode statsComputedAt: string | null createdAt: string } +/** Откуда группа берёт состав: явный список либо вычисление правилом. */ +export type GroupMode = 'Static' | 'Dynamic' + type GroupItemDto = { - id: string + /** null — элемент найден правилом динамической группы: своей строки, веса и порядка у него нет. */ + id: string | null elementKind: GroupElementKind elementId: string elementName: string @@ -264,18 +269,29 @@ type GroupItemDto = { audience: ShowAudience | null year: number | null posterImageId: string | null + /** Закреплён руками в динамической группе — правило его не выбрасывает. */ + pinned: boolean +} + +/** Элемент, исключённый из динамической группы вручную. */ +export type GroupExcludedDto = { + elementKind: GroupElementKind + elementId: string + elementName: string } export type GroupDto = { id: string name: string description: string | null + mode: GroupMode filter: GroupFilter | null itemCount: number unitCount: number totalDurationSeconds: number statsComputedAt: string | null items: GroupItemDto[] + excluded: GroupExcludedDto[] } /** Кандидат, найденный правилом набора. */ diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index 3cdc8b9..ad898f8 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -101,6 +101,21 @@ export const en = { found: 'Found: {{total}}, new: {{fresh}}', added: 'Items added: {{count}}', alreadyIn: 'already in group', + mode: { + label: 'Composition', + Static: 'Explicit list', + Dynamic: 'By rule', + StaticHint: 'Items are added by hand; the rule only helps to find them.', + DynamicHint: + 'Composition is computed by the rule on every read — new library items go on air by themselves.', + }, + pinned: 'pinned', + byRule: 'by rule', + excluded: 'Excluded', + excludeHint: 'Exclude from the composition — the rule will stop adding it back', + restore: 'Restore', + dynamicOrderHint: + 'Order: pinned items first (drag to arrange), then rule matches alphabetically.', pendingFound: 'New matches for this rule: {{count}} —', pendingAdd: 'Add all ({{count}})', elementKinds: { Show: 'Show', Collection: 'Collection' }, diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 9927243..30c4129 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -101,6 +101,21 @@ export const ru = { found: 'Найдено: {{total}}, новых: {{fresh}}', added: 'Добавлено позиций: {{count}}', alreadyIn: 'уже в группе', + mode: { + label: 'Состав', + Static: 'Явный список', + Dynamic: 'По правилу', + StaticHint: 'Позиции добавляются вручную; правило только помогает их найти.', + DynamicHint: + 'Состав считается правилом при каждом обращении — новое из библиотеки попадает в эфир само.', + }, + pinned: 'закреплено', + byRule: 'по правилу', + excluded: 'Исключено', + excludeHint: 'Исключить из состава — правило больше не будет его подставлять', + restore: 'Вернуть', + dynamicOrderHint: + 'Порядок: сначала закреплённое (перетаскиванием), затем найденное правилом по алфавиту.', pendingFound: 'Под правило группы подходит нового: {{count}} —', pendingAdd: 'Добавить все ({{count}})', elementKinds: { Show: 'Шоу', Collection: 'Коллекция' },