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: 'Коллекция' },