Add audience categorization for shows: implement ShowAudience type, update related API endpoints, and enhance frontend components for audience selection and display. Modify data structures to support audience information in show creation and retrieval processes.
build / backend (push) Successful in 3m35s
build / frontend (push) Successful in 52s
tests / backend-tests (push) Successful in 3m27s

This commit is contained in:
Leonid Pershin
2026-07-26 02:06:08 +03:00
parent 525fd2ee01
commit 8962840654
19 changed files with 1900 additions and 8 deletions
@@ -8,7 +8,9 @@ using TeleWave.Application.Library.GetShow;
using TeleWave.Application.Library.ListShows; using TeleWave.Application.Library.ListShows;
using TeleWave.Application.Library.RemoveEpisode; using TeleWave.Application.Library.RemoveEpisode;
using TeleWave.Application.Library.RenameShow; using TeleWave.Application.Library.RenameShow;
using TeleWave.Application.Library.SetShowAudience;
using TeleWave.Application.Library.SetShowOriginalName; using TeleWave.Application.Library.SetShowOriginalName;
using TeleWave.Domain.Library;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints; namespace TeleWave.Api.Endpoints;
@@ -28,6 +30,7 @@ public static class ShowEndpoints
admin admin
.MapPut("/{id:guid}/original-name", SetOriginalName) .MapPut("/{id:guid}/original-name", SetOriginalName)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent); admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
admin admin
.MapPost("/{id:guid}/episodes", AddEpisode) .MapPost("/{id:guid}/episodes", AddEpisode)
@@ -98,6 +101,20 @@ public static class ShowEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> SetAudience(
Guid id,
SetShowAudienceBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowAudienceCommand(id, body.Audience),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteShow( private static async Task<IResult> DeleteShow(
Guid id, Guid id,
ISender sender, ISender sender,
@@ -141,3 +158,5 @@ public sealed record AddEpisodeBody(Guid MediaAssetId);
public sealed record RenameShowBody(string Name); public sealed record RenameShowBody(string Name);
public sealed record SetShowOriginalNameBody(string? OriginalName); public sealed record SetShowOriginalNameBody(string? OriginalName);
public sealed record SetShowAudienceBody(ShowAudience Audience);
@@ -8,5 +8,6 @@ public sealed record CreateShowCommand(
string Name, string Name,
ShowKind Kind, ShowKind Kind,
string? Description, string? Description,
string? OriginalName = null string? OriginalName = null,
ShowAudience Audience = ShowAudience.General
) : ICommand<Result<Guid>>; ) : ICommand<Result<Guid>>;
@@ -14,7 +14,8 @@ public sealed class CreateShowCommandHandler(IAppDbContext dbContext)
command.Name, command.Name,
command.Kind, command.Kind,
command.Description, command.Description,
command.OriginalName command.OriginalName,
command.Audience
); );
dbContext.Shows.Add(show); dbContext.Shows.Add(show);
return Task.FromResult(Result.Success(show.Id)); return Task.FromResult(Result.Success(show.Id));
@@ -61,6 +61,7 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
show.Name, show.Name,
show.OriginalName, show.OriginalName,
show.Kind, show.Kind,
show.Audience,
show.Description, show.Description,
show.MetadataProvider, show.MetadataProvider,
show.MetadataExternalId, show.MetadataExternalId,
@@ -46,6 +46,7 @@ public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
s.Name, s.Name,
s.OriginalName, s.OriginalName,
s.Kind, s.Kind,
s.Audience,
s.Episodes.Count, s.Episodes.Count,
seasons, seasons,
s.Year, s.Year,
@@ -0,0 +1,8 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.SetShowAudience;
/// <summary>Задать категорию аудитории шоу (обычное/детское/взрослое).</summary>
public sealed record SetShowAudienceCommand(Guid Id, ShowAudience Audience) : ICommand<Result>;
@@ -0,0 +1,26 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.SetShowAudience;
public sealed class SetShowAudienceCommandHandler(IAppDbContext dbContext)
: ICommandHandler<SetShowAudienceCommand, Result>
{
public async Task<Result> Handle(
SetShowAudienceCommand command,
CancellationToken cancellationToken
)
{
var show = await dbContext.Shows.FirstOrDefaultAsync(
s => s.Id == command.Id,
cancellationToken
);
if (show is null)
return Result.Failure(ShowErrors.NotFound);
show.SetAudience(command.Audience);
return Result.Success();
}
}
@@ -8,6 +8,7 @@ public sealed record ShowSummaryDto(
string Name, string Name,
string? OriginalName, string? OriginalName,
ShowKind Kind, ShowKind Kind,
ShowAudience Audience,
int EpisodeCount, int EpisodeCount,
int SeasonCount, int SeasonCount,
int? Year, int? Year,
@@ -35,6 +36,7 @@ public sealed record ShowDto(
string Name, string Name,
string? OriginalName, string? OriginalName,
ShowKind Kind, ShowKind Kind,
ShowAudience Audience,
string? Description, string? Description,
string? MetadataProvider, string? MetadataProvider,
string? MetadataExternalId, string? MetadataExternalId,
+10 -1
View File
@@ -18,6 +18,10 @@ public class Show
public string? Description { get; private set; } public string? Description { get; private set; }
public ShowKind Kind { get; private set; } public ShowKind Kind { get; private set; }
/// <summary>Категория аудитории (обычное/детское/взрослое) — под будущие фильтры показа.</summary>
public ShowAudience Audience { get; private set; }
public DateTimeOffset CreatedAt { get; private set; } public DateTimeOffset CreatedAt { get; private set; }
// ── Метаданные (TMDb/OMDb/вручную) ── // ── Метаданные (TMDb/OMDb/вручную) ──
@@ -42,7 +46,8 @@ public class Show
string name, string name,
ShowKind kind, ShowKind kind,
string? description = null, string? description = null,
string? originalName = null string? originalName = null,
ShowAudience audience = ShowAudience.General
) => ) =>
new() new()
{ {
@@ -51,9 +56,13 @@ public class Show
OriginalName = Normalize(originalName), OriginalName = Normalize(originalName),
Kind = kind, Kind = kind,
Description = description, Description = description,
Audience = audience,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}; };
/// <summary>Задать категорию аудитории (обычное/детское/взрослое).</summary>
public void SetAudience(ShowAudience audience) => Audience = audience;
public void Rename(string name, string? description) public void Rename(string name, string? description)
{ {
Name = name; Name = name;
@@ -0,0 +1,14 @@
namespace TeleWave.Domain.Library;
/// <summary>Категория аудитории шоу (пригодится для фильтров/разграничения показа).</summary>
public enum ShowAudience
{
/// <summary>Обычное — без ограничений (по умолчанию).</summary>
General,
/// <summary>Детское.</summary>
Kids,
/// <summary>Взрослое.</summary>
Adult,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ShowAudience : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Audience",
table: "Shows",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Audience",
table: "Shows");
}
}
}
@@ -587,6 +587,9 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid>("Id") b.Property<Guid>("Id")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<int>("Audience")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt") b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
+691
View File
@@ -0,0 +1,691 @@
# Автоматическое построение расписания линейного канала
Спецификация для разработки. Система эмулирует линейный телеканал: непрерывный общий
эфир, привязанный к календарю, с публикуемой программой передач на N дней вперёд.
---
## 1. Основные принципы
### 1.1. Четыре слоя правил
Гибкость достигается не «конструктором расписаний», а разделением на четыре
независимых, переиспользуемых слоя:
| Слой | Сущность | Отвечает за |
|------|----------|-------------|
| 1 | **Пул** | Что может попасть в эфир |
| 2 | **Сетка** | Когда и в каком порядке |
| 3 | **Стратегия** | Как выбирается конкретный элемент |
| 4 | **Ограничение** | Чего нельзя допускать |
Слои ссылаются друг на друга, но редактируются отдельно. Один пул используется
многими слотами, одна стратегия — многими каналами.
### 1.2. Детерминированность
Генерация — чистая функция:
```
schedule(channel_id, date) → [элементы]
```
Одинаковые входные данные всегда дают одинаковый результат. Следствия:
- предпросмотр гарантированно совпадает с эфиром;
- перегенерация не «прыгает» без причины;
- можно посчитать любую дату независимо от соседних;
- нет накопленного состояния, которое рассинхронизируется.
### 1.3. Модель времени
- `channel.epoch` — дата запуска канала, точка отсчёта для последовательных стратегий.
- `channel.timezone` — таймзона канала. Эфир общий: в 20:00 все зрители видят одно и то же.
- `now` — единственная жёсткая граница между неизменяемым прошлым и пересчитываемым будущим.
---
## 2. Модель данных
### 2.1. Канал
```
Channel
id
name
slug
epoch date — точка отсчёта
timezone string
era_profile jsonb — профиль эпохи (см. 2.2)
grid_mode enum — hard | elastic
day_start_time time — вещательные сутки, обычно 06:00, не 00:00
status enum — draft | active | archived
rules_version int — инкремент при любой правке правил
```
**`day_start_time`** — важная деталь. У телеканалов сутки начинаются утром, а не в полночь.
Ночной блок с 00:00 до 06:00 относится к предыдущему дню. Это влияет на слои сетки
(«пятничная ночь» = ночь с пятницы на субботу) и на отображение программы.
**`grid_mode`**:
- `hard` — старты слотов фиксированы (:00, :30). Разница добирается врезками. Правильный
выбор для правдоподобной эмуляции.
- `elastic` — всё встык, времена слотов — цели с допуском дрейфа, выравнивание на якорях.
### 2.2. Профиль эпохи
Фильтр верхнего уровня, накрывающий все пулы канала сразу. Один переключатель задаёт
атмосферу и не даёт просочиться современному контенту.
```
era_profile
year_max int — ничего новее
year_min int — опционально
aspect_ratio enum — 4:3 | 16:9 | any
ident_pack_ids [uuid] — паки заставок
ad_pack_ids [uuid] — паки рекламных роликов
promo_pack_ids [uuid] — паки анонсов
```
Применяется как AND к любому пулу этого канала.
### 2.3. Пул (слой 1)
```
Pool
id
name
kind enum — query | manual | composite
filters jsonb — для kind=query
manual_items [item_ref] — для kind=manual
composite jsonb — для kind=composite: {include:[], exclude:[]}
cached_stats jsonb — {count, total_duration_ms, computed_at}
```
`filters` для `kind=query`:
```json
{
"content_types": ["show", "movie"],
"genres": { "any_of": ["comedy", "animation"] },
"tags": { "all_of": ["retro"], "none_of": ["holiday"] },
"year": { "min": 1989, "max": 1999 },
"duration_ms": { "min": 1200000, "max": 1500000 },
"age_rating": { "max": "12+" },
"languages": ["ru"],
"channels": ["cartoon-network"]
}
```
`cached_stats` пересчитывается фоново при изменении каталога — нужно для UI
(«подходит 342 позиции, 118 часов»).
### 2.4. Сетка и слот (слой 2)
```
GridLayer
id
channel_id
name — «Базовая», «Выходные», «Летняя», «Новый год»
priority int — больше = специфичнее, побеждает
applicability jsonb — когда действует
enabled bool
```
`applicability`:
```json
{
"weekdays": [1,2,3,4,5],
"date_ranges": [{"from": "2026-06-01", "to": "2026-08-31"}],
"specific_dates": ["2026-12-31"],
"recurring_dates": [{"month": 10, "day": 31}]
}
```
Разрешение конфликтов: для каждой минуты берётся слот из слоя с наибольшим `priority`
среди применимых. Базовый слой имеет `priority = 0`.
```
Slot
id
layer_id
weekday int — 0..6, null если слой привязан к датам
start_time time — в вещательных сутках канала
duration_ms int
title string — отображаемое имя блока («Вечернее кино»)
slot_type enum — content | repeat | anchor
pool_id uuid
strategy jsonb — см. 2.5
junction_template_id uuid
break_load jsonb — плотность врезок, см. 4.3
is_anchor bool — старт не сдвигается ни при каких условиях
daypart enum — morning | day | prime | night
```
**`slot_type = repeat`** — очень узнаваемая примета настоящего ТВ и бесплатное
расширение объёма контента:
```json
{
"slot_type": "repeat",
"repeat_source": { "days_ago": 1, "time": "20:00" }
}
```
### 2.5. Стратегия (слой 3)
Хранится внутри слота как JSON с полем `type`.
**sequential** — сериал по порядку:
```json
{
"type": "sequential",
"on_season_end": "next_season", // next_season | restart | stop
"start_from": { "season": 1, "episode": 1 }
}
```
**marathon** — блок серий подряд:
```json
{ "type": "marathon", "count": 4, "continue_across_days": true }
```
**rotation** — случайный выбор с остыванием:
```json
{ "type": "rotation", "cooldown_days": 7 }
```
**weighted** — вероятность по весу:
```json
{
"type": "weighted",
"weight_by": "popularity", // popularity | recency | manual
"cooldown_days": 3
}
```
**fixed** — всегда один и тот же тайтл:
```json
{ "type": "fixed", "item_ref": "movie:12345" }
```
**playlist** — жёсткий порядок:
```json
{ "type": "playlist", "items": ["show:1:s1e1", "movie:99"], "loop": true }
```
### 2.6. Шаблон стыка
Последовательность врезок между программами. Достаточно 3–4 шаблонов на канал.
```
JunctionTemplate
id
name — «Прайм», «День», «Ночь», «Внутри марафона»
elements jsonb[]
```
```json
{
"name": "Прайм",
"elements": [
{ "kind": "ad", "duration_ms": 120000, "required": true,
"flex": { "min": 60000, "max": 180000 } },
{ "kind": "ident", "required": false,
"condition": "minutes_since_last_ident > 30" },
{ "kind": "bumper_nextup", "required": false,
"condition": "next_slot.slot_type != 'repeat'" },
{ "kind": "promo", "required": false, "fill_remaining": true }
]
}
```
- `required` — элемент нельзя выбросить при нехватке времени.
- `flex` — диапазон, внутри которого элемент поглощает лишнее/недостающее время.
- `fill_remaining` — элемент растягивается на весь остаток слота.
- `condition` — выражение над контекстом (соседние слоты, время суток, счётчики).
### 2.7. Ограничения (слой 4)
```
Constraint
id
channel_id
scope enum — channel | daypart | slot
scope_ref uuid
type enum
params jsonb
severity enum — hard | soft
```
`hard` — генератор обязан соблюсти или упасть с ошибкой.
`soft` — старается соблюсти, при невозможности пишет предупреждение.
Типы:
| type | params | Смысл |
|------|--------|-------|
| `age_rating_by_time` | `{from, to, max_rating}` | Детское время |
| `max_title_repeats` | `{window_days, max}` | Не чаще N раз за период |
| `min_repeat_gap` | `{hours}` | Минимальный интервал между повторами |
| `max_break_minutes_per_hour` | `{minutes}` | Потолок врезок |
| `max_genre_share` | `{genre, share, window}` | Доля жанра в сутках |
| `forbidden_adjacency` | `{tags}` | Что нельзя ставить встык |
### 2.8. Результат генерации
Два представления одного расписания.
**Программная сетка** — то, что отдаётся клиентам:
```
ProgrammeEntry
id
channel_id
start_at timestamptz
end_at timestamptz
title
item_ref — show:id:s1e5 | movie:id
slot_id — что породило
layer_id — из какого слоя сетки
origin enum — generated | manual | fallback
is_frozen bool — прошедшее
```
**Плейаут-лента** — реальная последовательность воспроизведения:
```
PlayoutItem
id
channel_id
programme_entry_id — null для врезок между программами
start_at timestamptz
duration_ms
kind enum — programme | ad | promo | ident | bumper | filler
asset_id
source_ref — slot_id | junction_template_id
```
Плейаут выводится из программной сетки при генерации. Клиентам отдаётся только первое.
### 2.9. Ручные правки
Отдельная таблица, переживает перегенерацию.
```
Patch
id
channel_id
target_date date
target_time time
op enum — pin | replace | shift | remove | insert
payload jsonb
status enum — active | orphaned
created_by
created_at
```
- `pin` — закрепить конкретный элемент в этом времени.
- `replace` — заменить то, что сгенерировалось.
- `shift` — сдвинуть границу слота.
- `orphaned` — правила изменились так, что патч потерял смысл. Не применяется молча
и не выбрасывается молча — показывается админу списком.
---
## 3. Алгоритм генерации
### 3.1. Пайплайн
```
1. RESOLVE GRID — собрать эффективную сетку на дату из слоёв
2. FILL SLOTS — для каждого слота выбрать контент по стратегии
3. APPLY CONSTRAINTS — проверить ограничения, при нарушении — пересобрать слот
4. BUILD PLAYOUT — разложить врезки по шаблонам стыков, подогнать длительности
5. APPLY PATCHES — наложить ручные правки
6. VALIDATE — финальная проверка, собрать предупреждения
7. MATERIALIZE — записать в кэш будущего
```
### 3.2. Seed: гранулярность на уровне слота
**Критично для UX.** Если считать seed на уровне дня, любая мелкая правка
перетасовывает всю неделю, включая слоты, которых правка не касалась. Админ поменял
шаблон стыка в ночном блоке — переехало дневное вещание. Диф становится
бесполезным.
```
seed = hash(channel_id, date, slot_id)
```
**Правило: seed зависит только от координат (канал + дата + слот), но не от
содержания правил.** Изменилось правило — изменился результат его применения, но не
жребий соседей. Диф читается как «изменилось 4 элемента из 180».
### 3.3. Последовательные стратегии без состояния
Курсор в БД не нужен. Номер выхода вычисляется арифметически:
```
occurrences = количество срабатываний слота в интервале [channel.epoch, date)
episode_index = (occurrences + start_offset) mod total_episodes
```
`occurrences` считается по правилу повторения слоя (например, «будни» → количество
рабочих дней между датами) с вычетом дат, где слот был перекрыт слоем с более высоким
приоритетом.
Даёт: строго последовательный порядок + мгновенную перемотку на любую дату + нулевое
состояние.
Для `on_season_end = next_season` — тот же принцип, но по плоскому списку серий всех
сезонов.
### 3.4. Ротация с остыванием без состояния
Вместо хранения истории показов — детерминированная колода:
```
period = floor(days_since_epoch / cooldown_days)
deck = shuffle(pool_items, seed = hash(channel_id, slot_id, period))
index = day_within_period
item = deck[index mod len(deck)]
```
Колода перетасовывается раз в период, внутри периода повторов нет by design.
### 3.5. Подгонка длительности — главная рабочая часть
Слот 30 мин, серия 22 мин → 8 минут добора. Алгоритм:
```
gap = slot.duration_ms - content.duration_ms
if gap > 0:
заполнить по шаблону стыка:
1. required-элементы (сумма их min)
2. расширить flex-элементы до max в порядке приоритета
3. добавить optional-элементы, пока проходят условия
4. остаток отдать fill_remaining-элементу
5. если остаток всё ещё > 0 → filler
if gap < 0: # контент длиннее слота
grid_mode = hard:
а) искать в пуле элемент подходящей длительности (best-fit)
б) сжать врезки до min
в) если не помещается → расширить слот, сдвинув следующий
(но не якорь — перед якорем сжимается эластичная зона)
grid_mode = elastic:
сдвинуть последующие слоты, выровняться на ближайшем якоре
```
**Best-fit важнее «лучшего»**: при подборе в пул часто правильнее взять элемент,
ближе всего подходящий по длительности, чем элемент с наивысшим весом.
**Внутренние разрывы** (реклама внутри программы) ставятся по маркерам из метаданных,
при их отсутствии — через равные интервалы с запретом первых и последних 5 минут.
### 3.6. Плотность врезок по дейпартам
Реальный канал не идёт встык, и плотность меняется по времени суток.
```
break_load:
morning: { target_ratio: 0.12, max_break_ms: 90000 }
day: { target_ratio: 0.18, max_break_ms: 120000 }
prime: { target_ratio: 0.22, max_break_ms: 180000 }
night: { target_ratio: 0.10, max_break_ms: 240000 }
```
Ночью врезок меньше, но они длиннее — это узнаваемо.
### 3.7. Граница `now` и перегенерация
```
past → snapshot, immutable, никогда не пересчитывается
current → текущий элемент не вырезается из-под зрителя;
в grid_mode=hard применение начинается со следующего слота
future → materialized cache, пересобирается по требованию
patches → отдельная таблица, переживает перегенерацию
```
Перегенерация = удалить кэш будущего от границы применения → посчитать заново →
наложить патчи. Операция идемпотентна.
Прошлое замораживается снапшотом обязательно: иначе история будет врать — пользователь
смотрел вчера в 20:00 одно, а в архиве другое.
Фоновая задача каждую ночь достраивает горизонт (например, держим 14 дней вперёд).
### 3.8. Отдача клиентам
- Запрос программы — простой диапазон по `ProgrammeEntry`.
- Возвращать `version` / `ETag`, чтобы клиент не показывал устаревшую сетку из кэша.
- Плейаут-лента наружу не отдаётся.
---
## 4. Валидация и предупреждения
Считаются до генерации (по правилам) и после (по результату).
### 4.1. До генерации
| Проверка | Формула | Сообщение |
|----------|---------|-----------|
| Нехватка контента | `pool.count < выходов_слота_в_неделю` | «В пуле 3 позиции при потребности 7 в неделю — повторы каждые полнедели» |
| Пустой пул | `pool.count == 0` | «Пул пуст, слот будет заполнен филлером» |
| Дыра в сетке | непокрытый интервал | «Не покрыто: вт 03:00–06:00» |
| Пересечение слотов | overlap в одном слое | «Слоты пересекаются» |
| Недостижимое ограничение | hard-constraint vs объём пула | «Ограничение „не чаще 1 раза в неделю" невыполнимо при 3 позициях» |
**Расчёт достаточности пула:**
```
потребность = выходов_в_неделю
запас = pool.count / потребность # в неделях до полного цикла
```
Показывать в UI: «полный цикл без повторов — 6.2 недели».
### 4.2. После генерации
- частота повторов тайтла (тепловая карта);
- фактическая доля врезок по часам против лимита;
- элементы с `origin = fallback` (не нашлось контента);
- осиротевшие патчи;
- нарушенные soft-constraints.
---
## 5. UI: формы и экраны
### 5.1. Редактор сетки — основной экран
**Календарь недели с drag & drop**, как в Google Calendar. Не список форм.
- ось X — дни недели, ось Y — время вещательных суток (от `day_start_time`);
- слоты тянутся мышкой, изменяются за края;
- копирование дня на другие дни, копирование недели;
- цвет блока = дейпарт или тип слота;
- полупрозрачная штриховка на слотах, перекрытых слоем выше;
- панель слоёв слева: список с чекбоксами видимости и приоритетом, drag для
переупорядочивания.
**Инспектор слота** (правая панель, открывается по клику):
```
Название блока [Вечернее кино ]
Время / длительность [20:00] [90 мин] □ Якорь
Дейпарт (○ утро ○ день ● прайм ○ ночь)
Тип слота (● контент ○ повтор ○ якорь)
Пул [Фильмы 90-х ▾] 342 поз. · 118 ч · цикл 6.2 нед
[Открыть пул] [Создать новый]
Стратегия [Ротация с остыванием ▾]
Остывание [7] дней
Шаблон стыка [Прайм ▾] ~4 мин врезок
Плотность врезок [наследовать от дейпарта ▾]
Ограничения слота + Добавить
```
Ключевое: статистика пула («342 поз. · цикл 6.2 нед») видна прямо здесь, без перехода.
### 5.2. Редактор пула
Двухпанельный: слева конструктор фильтров, справа — живой список результатов с
пересчётом на каждое изменение.
```
Тип контента [x] Шоу [x] Фильмы
Жанры любой из: [комедия ×] [анимация ×] +
Теги все из: [ретро ×] +
ни одного: [праздничное ×] +
Год от [1989] до [1999]
Длительность от [20] до [25] мин
Возраст не выше [12+ ▾]
─────────────────────────────────
Найдено: 342 позиции · 118 ч 40 мин
Самое старое: 1989 · Самое новое: 1999
[список результатов с превью]
```
Профиль эпохи канала показывается как неотключаемый бейдж сверху: «Фильтр канала:
не новее 1999».
### 5.3. Редактор шаблона стыка
Визуальная горизонтальная цепочка, перетаскиванием.
```
[КОНЕЦ] → [Реклама 60–180с] → [Айдент 10с] → [Далее 15с] → [Промо ⇥] → [НАЧАЛО]
required if >30min if !repeat fill
```
Под цепочкой — линейка суммарной длительности: «мин 85с / типично 155с / макс 205с».
Клик по элементу — попап с параметрами (kind, длительность, flex, condition, required).
### 5.4. Предпросмотр
Кнопка «Предпросмотр на N дней» доступна из редактора **без сохранения** — считает по
текущему черновику правил.
Три вкладки:
**Программа** — список как увидит зритель, по дням.
**Плейаут** — посекундный таймлайн с цветовыми слоями (программа / реклама / айдент /
анонс / филлер). Сверху — гистограмма нагрузки врезок по часам с горизонтальной линией
лимита; превышения красным.
**Проблемы** — сгруппированный список предупреждений с переходом к источнику.
Дополнительно: тепловая карта повторов — матрица «тайтл × день», где яркость =
количество показов. Мгновенно видно, что один фильм крутится четыре раза за неделю.
### 5.5. «Почему это здесь»
У каждого элемента расписания — иконка «i», открывающая цепочку происхождения:
```
Симпсоны, с5э12 · пн 18:00
Слой: Базовая сетка (priority 0)
Слот: Дневная анимация · будни 18:00 · 30 мин
Пул: Мультсериалы 90-х (128 позиций)
Стратегия: Последовательная
выход №247 с 01.01.2024 → серия 247 mod 178 = 69
Врезки: шаблон «День», 8 мин добора
Патчи: нет
```
Это экономит редакторам часы отладки — обязательный, не опциональный элемент.
### 5.6. Диф перед применением
Уведомлений клиентам нет, но админу нужен предохранитель.
```
Изменения затронут:
180 элементов всего, из них 12 изменятся
3 — в ближайшие 24 часа ⚠
пн 20:00 «Терминатор» → «Чужой»
пн 21:40 реклама 120с → реклама 180с
вт 18:00 без изменений
...
[Применить] [Отмена]
```
Отдельно подсвечивать изменения в ближайшие сутки — самая частая причина случайного
ущерба.
### 5.7. Прочие экраны
- **Список каналов** с кнопкой «Клонировать» — сделал один канал, скопировал, поменял
пулы, получил второй. Основной способ масштабирования.
- **История версий правил**: кто, когда, что изменил; откат к версии.
- **Патчи**: список ручных правок, фильтр по статусу, массовое снятие осиротевших.
- **Расписание (обзор)**: чтение на любую дату, включая архив прошлого.
### 5.8. Люк вниз
Правила хранятся в JSONB со схемой. Визуальный редактор и «сырой» JSON правят одну и ту
же структуру. Для продвинутых пользователей — вкладка с JSON-редактором и валидацией по
схеме. Это же даёт нормальный дифф в истории версий.
---
## 6. Что делает эмуляцию правдоподобной
Отдельный раздел, потому что это влияет на дефолты и на то, что вынести в UI явно.
1. **Стабильность сетки.** Настоящее ТВ предсказуемо: одно шоу всегда в одно время.
Слоты прибиты, а не рандомятся каждый день. Дефолт — `grid_mode = hard`.
2. **Характер дейпартов.** Утро — короткие детские; день — повторы и дешёвый контент;
прайм — премьеры и полнометражки; ночь — старое кино, документалки, длинные блоки.
Реализуется просто разными пулами и шаблонами стыков.
3. **Повторы как фича, а не баг.** Вечерний блок повторяется утром следующего дня —
очень узнаваемо. Тип слота `repeat`.
4. **Вещательные сутки с 06:00**, а не с полуночи.
5. **Ритм врезок**: чем ближе к прайму, тем плотнее. Ночью реже и длиннее.
6. **Ротация айдентов с остыванием** — не крутить один и тот же чаще раза в час.
7. **Сезонные слои**: летняя сетка, новогодняя, тематические дни.
---
## 7. Порядок реализации
**Этап 1 — ядро**
Канал, пул (kind=query), базовый слой сетки, слоты, стратегии sequential и rotation,
генератор без врезок, материализация, отдача программы.
**Этап 2 — врезки**
Шаблоны стыков, подгонка длительности, плотность по дейпартам, плейаут-лента.
**Этап 3 — гибкость**
Слои сетки с приоритетами, остальные стратегии, ограничения, тип слота `repeat`.
**Этап 4 — админка**
Календарь с drag & drop, инспектор слота, редактор пула со статистикой,
предпросмотр, «почему это здесь».
**Этап 5 — эксплуатация**
Патчи, диф, версионирование и откат, клонирование канала, валидация, тепловые карты.
---
## 8. Открытые вопросы
- Нужна ли поддержка нескольких таймзон для одного канала (условные «орбиты» +2/+4)?
Если да — это тонкая надстройка над той же лентой со сдвигом, а не отдельный канал.
- Нужны ли «прямые эфиры» / премьеры по расписанию как отдельный тип якоря?
- Хранить ли плейаут-ленту материализованно или считать на лету из программной сетки
при запросе. Зависит от нагрузки на плеер.
@@ -4,11 +4,12 @@ import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ChevronLeft } from 'lucide-react' import { ChevronLeft } from 'lucide-react'
import { HttpError } from '@/shared/api/client' import { HttpError } from '@/shared/api/client'
import type { MediaAssetDto } from '@/shared/api/types' import type { MediaAssetDto, ShowAudience } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { Pager } from '@/shared/ui/pager' import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store' import { toast } from '@/shared/ui/toast-store'
import { listAllMedia } from '@/features/admin/media/api' import { listAllMedia } from '@/features/admin/media/api'
import { import {
@@ -20,7 +21,7 @@ import {
import { formatDuration } from '@/features/admin/media/MediaPanel' import { formatDuration } from '@/features/admin/media/MediaPanel'
import { ShowMetadataCard } from './ShowMetadataCard' import { ShowMetadataCard } from './ShowMetadataCard'
import { imageUrl } from '@/features/admin/images/api' import { imageUrl } from '@/features/admin/images/api'
import { addEpisode, getShow, removeEpisode } from './api' import { addEpisode, getShow, removeEpisode, setShowAudience } from './api'
type Candidate = { asset: MediaAssetDto; parsed: ParsedEpisode } type Candidate = { asset: MediaAssetDto; parsed: ParsedEpisode }
@@ -48,6 +49,12 @@ export function ShowDetail({ showId }: { showId: string }) {
const onError = (error: unknown) => const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error')) toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const audienceMutation = useMutation({
mutationFn: (audience: ShowAudience) => setShowAudience(showId, audience),
onSuccess: invalidate,
onError,
})
const removeMutation = useMutation({ const removeMutation = useMutation({
mutationFn: (episodeId: string) => removeEpisode(showId, episodeId), mutationFn: (episodeId: string) => removeEpisode(showId, episodeId),
onSuccess: invalidate, onSuccess: invalidate,
@@ -141,6 +148,19 @@ export function ShowDetail({ showId }: { showId: string }) {
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<h2 className="crt-glow text-xl font-semibold">{show.name}</h2> <h2 className="crt-glow text-xl font-semibold">{show.name}</h2>
<Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge> <Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge>
<Select
value={show.audience}
onValueChange={(v) => audienceMutation.mutate(v as ShowAudience)}
>
<SelectTrigger className="h-7 w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="General">{t('admin.shows.audiences.General')}</SelectItem>
<SelectItem value="Kids">{t('admin.shows.audiences.Kids')}</SelectItem>
<SelectItem value="Adult">{t('admin.shows.audiences.Adult')}</SelectItem>
</SelectContent>
</Select>
{show.kind === 'Series' && ( {show.kind === 'Series' && (
<Badge variant="muted"> <Badge variant="muted">
{t('admin.shows.seasons')}: {seasons.length} {t('admin.shows.seasons')}: {seasons.length}
@@ -3,7 +3,7 @@ import { Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client' import { HttpError } from '@/shared/api/client'
import type { ShowKind } from '@/shared/api/types' import type { ShowAudience, ShowKind } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
@@ -21,6 +21,7 @@ export function ShowsPanel() {
const [name, setName] = useState('') const [name, setName] = useState('')
const [originalName, setOriginalName] = useState('') const [originalName, setOriginalName] = useState('')
const [kind, setKind] = useState<ShowKind>('Series') const [kind, setKind] = useState<ShowKind>('Series')
const [audience, setAudience] = useState<ShowAudience>('General')
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const { sort, toggle } = useTableSort('name', false) const { sort, toggle } = useTableSort('name', false)
@@ -44,6 +45,7 @@ export function ShowsPanel() {
return sortRows(matched, sort, { return sortRows(matched, sort, {
name: (s) => s.name.toLowerCase(), name: (s) => s.name.toLowerCase(),
kind: (s) => s.kind, kind: (s) => s.kind,
audience: (s) => s.audience,
seasons: (s) => s.seasonCount, seasons: (s) => s.seasonCount,
episodes: (s) => s.episodeCount, episodes: (s) => s.episodeCount,
}) })
@@ -56,7 +58,12 @@ export function ShowsPanel() {
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: () => mutationFn: () =>
createShow({ name: name.trim(), kind, originalName: originalName.trim() || undefined }), createShow({
name: name.trim(),
kind,
originalName: originalName.trim() || undefined,
audience,
}),
onSuccess: () => { onSuccess: () => {
setName('') setName('')
setOriginalName('') setOriginalName('')
@@ -92,6 +99,16 @@ export function ShowsPanel() {
<SelectItem value="Single">{t('admin.shows.kinds.Single')}</SelectItem> <SelectItem value="Single">{t('admin.shows.kinds.Single')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={audience} onValueChange={(v) => setAudience(v as ShowAudience)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="General">{t('admin.shows.audiences.General')}</SelectItem>
<SelectItem value="Kids">{t('admin.shows.audiences.Kids')}</SelectItem>
<SelectItem value="Adult">{t('admin.shows.audiences.Adult')}</SelectItem>
</SelectContent>
</Select>
<Button <Button
size="sm" size="sm"
disabled={!name.trim() || createMutation.isPending} disabled={!name.trim() || createMutation.isPending}
@@ -127,6 +144,12 @@ export function ShowsPanel() {
sort={sort} sort={sort}
onToggle={sortColumn} onToggle={sortColumn}
/> />
<SortHeader
label={t('admin.shows.audience')}
sortKey="audience"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader <SortHeader
label={t('admin.shows.seasons')} label={t('admin.shows.seasons')}
sortKey="seasons" sortKey="seasons"
@@ -145,7 +168,7 @@ export function ShowsPanel() {
<tbody> <tbody>
{isLoading && ( {isLoading && (
<tr> <tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={5}> <td className="px-4 py-3 text-muted-foreground" colSpan={6}>
{t('common.loading')} {t('common.loading')}
</td> </td>
</tr> </tr>
@@ -164,6 +187,9 @@ export function ShowsPanel() {
<td className="px-4 py-2"> <td className="px-4 py-2">
<Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge> <Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge>
</td> </td>
<td className="px-4 py-2">
<Badge variant="muted">{t(`admin.shows.audiences.${show.audience}`)}</Badge>
</td>
<td className="px-4 py-2 text-muted-foreground">{show.seasonCount}</td> <td className="px-4 py-2 text-muted-foreground">{show.seasonCount}</td>
<td className="px-4 py-2 text-muted-foreground">{show.episodeCount}</td> <td className="px-4 py-2 text-muted-foreground">{show.episodeCount}</td>
<td className="px-4 py-2"> <td className="px-4 py-2">
+6
View File
@@ -2,6 +2,7 @@ import { apiRequest } from '@/shared/api/client'
import type { import type {
CreatedIdResponse, CreatedIdResponse,
MetadataCandidate, MetadataCandidate,
ShowAudience,
ShowDto, ShowDto,
ShowKind, ShowKind,
ShowSummaryDto, ShowSummaryDto,
@@ -20,10 +21,15 @@ export function createShow(body: {
kind: ShowKind kind: ShowKind
description?: string description?: string
originalName?: string originalName?: string
audience?: ShowAudience
}) { }) {
return apiRequest<CreatedIdResponse>('/admin/shows', { method: 'POST', body }) return apiRequest<CreatedIdResponse>('/admin/shows', { method: 'POST', body })
} }
export function setShowAudience(id: string, audience: ShowAudience) {
return apiRequest<void>(`/admin/shows/${id}/audience`, { method: 'PUT', body: { audience } })
}
export function renameShow(id: string, name: string) { export function renameShow(id: string, name: string) {
return apiRequest<void>(`/admin/shows/${id}/name`, { method: 'PUT', body: { name } }) return apiRequest<void>(`/admin/shows/${id}/name`, { method: 'PUT', body: { name } })
} }
+5
View File
@@ -72,11 +72,15 @@ export type MediaStatsDto = {
// ── Библиотека (шоу) ─────────────────────────────────────────────────────── // ── Библиотека (шоу) ───────────────────────────────────────────────────────
export type ShowKind = 'Series' | 'Single' export type ShowKind = 'Series' | 'Single'
/** Категория аудитории: обычное / детское / взрослое. */
export type ShowAudience = 'General' | 'Kids' | 'Adult'
export type ShowSummaryDto = { export type ShowSummaryDto = {
id: string id: string
name: string name: string
originalName: string | null originalName: string | null
kind: ShowKind kind: ShowKind
audience: ShowAudience
episodeCount: number episodeCount: number
seasonCount: number seasonCount: number
year: number | null year: number | null
@@ -121,6 +125,7 @@ export type ShowDto = {
name: string name: string
originalName: string | null originalName: string | null
kind: ShowKind kind: ShowKind
audience: ShowAudience
description: string | null description: string | null
metadataProvider: string | null metadataProvider: string | null
metadataExternalId: string | null metadataExternalId: string | null
+4
View File
@@ -196,6 +196,8 @@ const resources = {
originalName: 'Оригинальное название (eng)', originalName: 'Оригинальное название (eng)',
kind: 'Тип', kind: 'Тип',
kinds: { Series: 'Сериал', Single: 'Полнометражка' }, kinds: { Series: 'Сериал', Single: 'Полнометражка' },
audience: 'Категория',
audiences: { General: 'Обычное', Kids: 'Детское', Adult: 'Взрослое' },
seasons: 'Сезоны', seasons: 'Сезоны',
loadedSeasons: 'Загружены сезоны', loadedSeasons: 'Загружены сезоны',
episodes: 'Серии', episodes: 'Серии',
@@ -576,6 +578,8 @@ const resources = {
originalName: 'Original name (eng)', originalName: 'Original name (eng)',
kind: 'Kind', kind: 'Kind',
kinds: { Series: 'Series', Single: 'Movie' }, kinds: { Series: 'Series', Single: 'Movie' },
audience: 'Category',
audiences: { General: 'General', Kids: 'Kids', Adult: 'Adult' },
seasons: 'Seasons', seasons: 'Seasons',
loadedSeasons: 'Loaded seasons', loadedSeasons: 'Loaded seasons',
episodes: 'Episodes', episodes: 'Episodes',