diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupFilterMatcher.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupFilterMatcher.cs index 046b528..248735c 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/GroupFilterMatcher.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/GroupFilterMatcher.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Library; using TeleWave.Domain.Programming; namespace TeleWave.Application.Programming.Groups; @@ -63,6 +64,11 @@ public sealed class GroupFilterMatcher(IAppDbContext dbContext) if (filter.ShowKinds is { Count: > 0 } kinds) shows = shows.Where(s => kinds.Contains(s.Kind)); + else + // Ролики в набор сами не идут: они живут отдельной библиотекой (см. 6.7), и правило без + // указанного типа означает «кино и сериалы», а не «всё, что есть в базе». Группу роликов + // собирают, отметив тип «Ролик» явно. + shows = shows.Where(s => s.Kind != ShowKind.Interstitial); // Рейтинги упорядочены по возрастанию строгости, поэтому «не строже» — обычное сравнение. // Шоу без рейтинга остаются в выборке — ровно как в планировщике (ElementSelector): источники diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GroupFilterMatcherTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GroupFilterMatcherTests.cs index a43c0ce..6297f10 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/GroupFilterMatcherTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/GroupFilterMatcherTests.cs @@ -26,6 +26,40 @@ public class GroupFilterMatcherTests Assert.Equal([(GroupElementKind.Show, movie.Id)], matched); } + /// Группа рекламы собирается тем же правилом — иначе её нечем набрать, кроме как руками. + [Fact] + public async Task ShowKinds_Interstitial_SelectsClips() + { + var fixture = new TestDb(); + var movie = Show.Create("Фильм", ShowKind.Single); + var clip = Show.Create("Ролик", ShowKind.Interstitial); + await SeedAsync(fixture, movie, clip); + + var matched = await MatchAsync( + fixture, + new GroupFilter(ShowKinds: [ShowKind.Interstitial]) + ); + + Assert.Equal([(GroupElementKind.Show, clip.Id)], matched); + } + + /// + /// Без указанного типа правило означает «кино и сериалы»: ролики живут отдельной библиотекой, + /// и сотня рекламных вставок в группе фильмов — это не «широкий отбор», а сломанный эфир. + /// + [Fact] + public async Task ShowKinds_Empty_LeavesOutClips() + { + var fixture = new TestDb(); + var movie = Show.Create("Фильм", ShowKind.Single); + var clip = Show.Create("Ролик", ShowKind.Interstitial); + await SeedAsync(fixture, movie, clip); + + var matched = await MatchAsync(fixture, new GroupFilter()); + + Assert.Equal([(GroupElementKind.Show, movie.Id)], matched); + } + [Fact] public async Task MaxAudience_KeepsUnrated() { diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index ea14d95..632bd88 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -221,6 +221,12 @@ GroupItem } ``` +`showKinds` принимает и `interstitial`: группа рекламы собирается тем же правилом, что и группа кино, +иначе набрать её можно было бы только руками. Но **не указанный `showKinds` означает «кино и +сериалы», а не «всё подряд»**: ролики живут отдельной библиотекой (см. 6.7), и сотня рекламных +вставок, молча просочившаяся в группу фильмов, — это не широкий отбор, а сломанный эфир. Чтобы они +попали в набор, тип нужно отметить явно. + **Веса.** `GroupItem.weight` смещает вероятность внутри группы при случайном выборе. Нужен для реального случая: в группе на 200 боевиков полтора десятка сильных, остальное — наполнение; с одним только остыванием хорошее утонет в среднем. diff --git a/frontend/src/features/admin/groups/GroupFilterPanel.tsx b/frontend/src/features/admin/groups/GroupFilterPanel.tsx index be8c66a..bad1432 100644 --- a/frontend/src/features/admin/groups/GroupFilterPanel.tsx +++ b/frontend/src/features/admin/groups/GroupFilterPanel.tsx @@ -8,7 +8,11 @@ import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' const ELEMENT_KINDS: GroupElementKind[] = ['Show', 'Collection'] -const SHOW_KINDS: ShowKind[] = ['Series', 'Single'] +/** + * Ролики в списке есть намеренно: группа рекламы собирается тем же правилом, что и группа кино. + * Сами по себе они в набор не попадают — сервер отдаёт их, только если тип отмечен явно. + */ +const SHOW_KINDS: ShowKind[] = ['Series', 'Single', 'Interstitial'] /** Конструктор правила набора. Правило не применяется само — оно только ищет кандидатов. */ export function GroupFilterPanel({ @@ -63,6 +67,7 @@ export function GroupFilterPanel({ ))} +

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

diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index c55d63f..57ae6d6 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -147,6 +147,8 @@ export const en = { hint: 'In an explicit list the rule only finds candidates; in a rule-based group it is the composition.', elementKinds: 'What to search', showKinds: 'Show type', + showKindsHint: + 'Unchecked means films and series. Clips live in their own library and join the selection only when checked explicitly.', genres: 'Genres', genresHint: 'Any of the checked ones.', maxAudience: 'No stricter than', diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 17de4f5..7611924 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -147,6 +147,8 @@ export const ru = { hint: 'В явном списке правило только ищет кандидатов; в группе «по правилу» оно и есть состав.', elementKinds: 'Что искать', showKinds: 'Тип шоу', + showKindsHint: + 'Не отмечено — кино и сериалы. Ролики живут отдельной библиотекой и попадают в набор, только если отметить их явно.', genres: 'Жанры', genresHint: 'Любой из отмеченных.', maxAudience: 'Возраст не строже',