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.
This commit is contained in:
@@ -8,7 +8,9 @@ using TeleWave.Application.Library.GetShow;
|
||||
using TeleWave.Application.Library.ListShows;
|
||||
using TeleWave.Application.Library.RemoveEpisode;
|
||||
using TeleWave.Application.Library.RenameShow;
|
||||
using TeleWave.Application.Library.SetShowAudience;
|
||||
using TeleWave.Application.Library.SetShowOriginalName;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
@@ -28,6 +30,7 @@ public static class ShowEndpoints
|
||||
admin
|
||||
.MapPut("/{id:guid}/original-name", SetOriginalName)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapPost("/{id:guid}/episodes", AddEpisode)
|
||||
@@ -98,6 +101,20 @@ public static class ShowEndpoints
|
||||
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(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
@@ -141,3 +158,5 @@ public sealed record AddEpisodeBody(Guid MediaAssetId);
|
||||
public sealed record RenameShowBody(string Name);
|
||||
|
||||
public sealed record SetShowOriginalNameBody(string? OriginalName);
|
||||
|
||||
public sealed record SetShowAudienceBody(ShowAudience Audience);
|
||||
|
||||
@@ -8,5 +8,6 @@ public sealed record CreateShowCommand(
|
||||
string Name,
|
||||
ShowKind Kind,
|
||||
string? Description,
|
||||
string? OriginalName = null
|
||||
string? OriginalName = null,
|
||||
ShowAudience Audience = ShowAudience.General
|
||||
) : ICommand<Result<Guid>>;
|
||||
|
||||
@@ -14,7 +14,8 @@ public sealed class CreateShowCommandHandler(IAppDbContext dbContext)
|
||||
command.Name,
|
||||
command.Kind,
|
||||
command.Description,
|
||||
command.OriginalName
|
||||
command.OriginalName,
|
||||
command.Audience
|
||||
);
|
||||
dbContext.Shows.Add(show);
|
||||
return Task.FromResult(Result.Success(show.Id));
|
||||
|
||||
@@ -61,6 +61,7 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
|
||||
show.Name,
|
||||
show.OriginalName,
|
||||
show.Kind,
|
||||
show.Audience,
|
||||
show.Description,
|
||||
show.MetadataProvider,
|
||||
show.MetadataExternalId,
|
||||
|
||||
@@ -46,6 +46,7 @@ public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
|
||||
s.Name,
|
||||
s.OriginalName,
|
||||
s.Kind,
|
||||
s.Audience,
|
||||
s.Episodes.Count,
|
||||
seasons,
|
||||
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>;
|
||||
+26
@@ -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? OriginalName,
|
||||
ShowKind Kind,
|
||||
ShowAudience Audience,
|
||||
int EpisodeCount,
|
||||
int SeasonCount,
|
||||
int? Year,
|
||||
@@ -35,6 +36,7 @@ public sealed record ShowDto(
|
||||
string Name,
|
||||
string? OriginalName,
|
||||
ShowKind Kind,
|
||||
ShowAudience Audience,
|
||||
string? Description,
|
||||
string? MetadataProvider,
|
||||
string? MetadataExternalId,
|
||||
|
||||
@@ -18,6 +18,10 @@ public class Show
|
||||
|
||||
public string? Description { get; private set; }
|
||||
public ShowKind Kind { get; private set; }
|
||||
|
||||
/// <summary>Категория аудитории (обычное/детское/взрослое) — под будущие фильтры показа.</summary>
|
||||
public ShowAudience Audience { get; private set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; private set; }
|
||||
|
||||
// ── Метаданные (TMDb/OMDb/вручную) ──
|
||||
@@ -42,7 +46,8 @@ public class Show
|
||||
string name,
|
||||
ShowKind kind,
|
||||
string? description = null,
|
||||
string? originalName = null
|
||||
string? originalName = null,
|
||||
ShowAudience audience = ShowAudience.General
|
||||
) =>
|
||||
new()
|
||||
{
|
||||
@@ -51,9 +56,13 @@ public class Show
|
||||
OriginalName = Normalize(originalName),
|
||||
Kind = kind,
|
||||
Description = description,
|
||||
Audience = audience,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
/// <summary>Задать категорию аудитории (обычное/детское/взрослое).</summary>
|
||||
public void SetAudience(ShowAudience audience) => Audience = audience;
|
||||
|
||||
public void Rename(string name, string? description)
|
||||
{
|
||||
Name = name;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace TeleWave.Domain.Library;
|
||||
|
||||
/// <summary>Категория аудитории шоу (пригодится для фильтров/разграничения показа).</summary>
|
||||
public enum ShowAudience
|
||||
{
|
||||
/// <summary>Обычное — без ограничений (по умолчанию).</summary>
|
||||
General,
|
||||
|
||||
/// <summary>Детское.</summary>
|
||||
Kids,
|
||||
|
||||
/// <summary>Взрослое.</summary>
|
||||
Adult,
|
||||
}
|
||||
+1026
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")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Audience")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
|
||||
@@ -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 { ChevronLeft } from 'lucide-react'
|
||||
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 { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Pager } from '@/shared/ui/pager'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { listAllMedia } from '@/features/admin/media/api'
|
||||
import {
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
import { formatDuration } from '@/features/admin/media/MediaPanel'
|
||||
import { ShowMetadataCard } from './ShowMetadataCard'
|
||||
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 }
|
||||
|
||||
@@ -48,6 +49,12 @@ export function ShowDetail({ showId }: { showId: string }) {
|
||||
const onError = (error: unknown) =>
|
||||
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({
|
||||
mutationFn: (episodeId: string) => removeEpisode(showId, episodeId),
|
||||
onSuccess: invalidate,
|
||||
@@ -141,6 +148,19 @@ export function ShowDetail({ showId }: { showId: string }) {
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="crt-glow text-xl font-semibold">{show.name}</h2>
|
||||
<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' && (
|
||||
<Badge variant="muted">
|
||||
{t('admin.shows.seasons')}: {seasons.length}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link } from '@tanstack/react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
@@ -21,6 +21,7 @@ export function ShowsPanel() {
|
||||
const [name, setName] = useState('')
|
||||
const [originalName, setOriginalName] = useState('')
|
||||
const [kind, setKind] = useState<ShowKind>('Series')
|
||||
const [audience, setAudience] = useState<ShowAudience>('General')
|
||||
const [query, setQuery] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const { sort, toggle } = useTableSort('name', false)
|
||||
@@ -44,6 +45,7 @@ export function ShowsPanel() {
|
||||
return sortRows(matched, sort, {
|
||||
name: (s) => s.name.toLowerCase(),
|
||||
kind: (s) => s.kind,
|
||||
audience: (s) => s.audience,
|
||||
seasons: (s) => s.seasonCount,
|
||||
episodes: (s) => s.episodeCount,
|
||||
})
|
||||
@@ -56,7 +58,12 @@ export function ShowsPanel() {
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createShow({ name: name.trim(), kind, originalName: originalName.trim() || undefined }),
|
||||
createShow({
|
||||
name: name.trim(),
|
||||
kind,
|
||||
originalName: originalName.trim() || undefined,
|
||||
audience,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setName('')
|
||||
setOriginalName('')
|
||||
@@ -92,6 +99,16 @@ export function ShowsPanel() {
|
||||
<SelectItem value="Single">{t('admin.shows.kinds.Single')}</SelectItem>
|
||||
</SelectContent>
|
||||
</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
|
||||
size="sm"
|
||||
disabled={!name.trim() || createMutation.isPending}
|
||||
@@ -127,6 +144,12 @@ export function ShowsPanel() {
|
||||
sort={sort}
|
||||
onToggle={sortColumn}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.shows.audience')}
|
||||
sortKey="audience"
|
||||
sort={sort}
|
||||
onToggle={sortColumn}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.shows.seasons')}
|
||||
sortKey="seasons"
|
||||
@@ -145,7 +168,7 @@ export function ShowsPanel() {
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<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')}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -164,6 +187,9 @@ export function ShowsPanel() {
|
||||
<td className="px-4 py-2">
|
||||
<Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge>
|
||||
</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.episodeCount}</td>
|
||||
<td className="px-4 py-2">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { apiRequest } from '@/shared/api/client'
|
||||
import type {
|
||||
CreatedIdResponse,
|
||||
MetadataCandidate,
|
||||
ShowAudience,
|
||||
ShowDto,
|
||||
ShowKind,
|
||||
ShowSummaryDto,
|
||||
@@ -20,10 +21,15 @@ export function createShow(body: {
|
||||
kind: ShowKind
|
||||
description?: string
|
||||
originalName?: string
|
||||
audience?: ShowAudience
|
||||
}) {
|
||||
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) {
|
||||
return apiRequest<void>(`/admin/shows/${id}/name`, { method: 'PUT', body: { name } })
|
||||
}
|
||||
|
||||
@@ -72,11 +72,15 @@ export type MediaStatsDto = {
|
||||
// ── Библиотека (шоу) ───────────────────────────────────────────────────────
|
||||
export type ShowKind = 'Series' | 'Single'
|
||||
|
||||
/** Категория аудитории: обычное / детское / взрослое. */
|
||||
export type ShowAudience = 'General' | 'Kids' | 'Adult'
|
||||
|
||||
export type ShowSummaryDto = {
|
||||
id: string
|
||||
name: string
|
||||
originalName: string | null
|
||||
kind: ShowKind
|
||||
audience: ShowAudience
|
||||
episodeCount: number
|
||||
seasonCount: number
|
||||
year: number | null
|
||||
@@ -121,6 +125,7 @@ export type ShowDto = {
|
||||
name: string
|
||||
originalName: string | null
|
||||
kind: ShowKind
|
||||
audience: ShowAudience
|
||||
description: string | null
|
||||
metadataProvider: string | null
|
||||
metadataExternalId: string | null
|
||||
|
||||
@@ -196,6 +196,8 @@ const resources = {
|
||||
originalName: 'Оригинальное название (eng)',
|
||||
kind: 'Тип',
|
||||
kinds: { Series: 'Сериал', Single: 'Полнометражка' },
|
||||
audience: 'Категория',
|
||||
audiences: { General: 'Обычное', Kids: 'Детское', Adult: 'Взрослое' },
|
||||
seasons: 'Сезоны',
|
||||
loadedSeasons: 'Загружены сезоны',
|
||||
episodes: 'Серии',
|
||||
@@ -576,6 +578,8 @@ const resources = {
|
||||
originalName: 'Original name (eng)',
|
||||
kind: 'Kind',
|
||||
kinds: { Series: 'Series', Single: 'Movie' },
|
||||
audience: 'Category',
|
||||
audiences: { General: 'General', Kids: 'Kids', Adult: 'Adult' },
|
||||
seasons: 'Seasons',
|
||||
loadedSeasons: 'Loaded seasons',
|
||||
episodes: 'Episodes',
|
||||
|
||||
Reference in New Issue
Block a user