diff --git a/.env.example b/.env.example
index 0204e98..0448261 100644
--- a/.env.example
+++ b/.env.example
@@ -92,6 +92,11 @@ Media__LoudnessTargetLufs=-16
Metadata__Language=ru-RU
Metadata__Tmdb__ApiKey=
Metadata__Omdb__ApiKey=
+# Страны возрастной сертификации TMDb в порядке приоритета: берётся первая, у которой рейтинг есть.
+# US впереди намеренно — его шкала (G/PG/PG-13/R/NC-17) совпадает с нашей и переводится точно;
+# RU (0+…18+) приводится приблизительно и нужен ради покрытия там, где US-сертификации нет.
+# Metadata__Tmdb__CertificationCountries__0=US
+# Metadata__Tmdb__CertificationCountries__1=RU
# ── ASP.NET Core ──────────────────────────────────────────────────────────
ASPNETCORE_ENVIRONMENT=Production
diff --git a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs
index 7a7fc61..afa1115 100644
--- a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs
+++ b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs
@@ -177,6 +177,7 @@ public sealed record RenameShowBody(string Name);
public sealed record SetShowOriginalNameBody(string? OriginalName);
-public sealed record SetShowAudienceBody(ShowAudience Audience);
+/// Рейтинг шоу; null — снять проставленный.
+public sealed record SetShowAudienceBody(ShowAudience? Audience);
public sealed record SetShowGenresBody(IReadOnlyList GenreIds, Guid? PrimaryGenreId);
diff --git a/backend/src/TeleWave.Application/Library/Collections/CollectionDtos.cs b/backend/src/TeleWave.Application/Library/Collections/CollectionDtos.cs
index 3985fc8..f050053 100644
--- a/backend/src/TeleWave.Application/Library/Collections/CollectionDtos.cs
+++ b/backend/src/TeleWave.Application/Library/Collections/CollectionDtos.cs
@@ -20,7 +20,7 @@ public sealed record CollectionItemDto(
int Position,
string ShowName,
ShowKind ShowKind,
- ShowAudience ShowAudience,
+ ShowAudience? ShowAudience,
int EpisodeCount,
int? Year,
Guid? PosterImageId
diff --git a/backend/src/TeleWave.Application/Library/Collections/GetCollection/GetCollectionQueryHandler.cs b/backend/src/TeleWave.Application/Library/Collections/GetCollection/GetCollectionQueryHandler.cs
index c6a1ef6..a81fb4a 100644
--- a/backend/src/TeleWave.Application/Library/Collections/GetCollection/GetCollectionQueryHandler.cs
+++ b/backend/src/TeleWave.Application/Library/Collections/GetCollection/GetCollectionQueryHandler.cs
@@ -46,7 +46,7 @@ public sealed class GetCollectionQueryHandler(IAppDbContext dbContext)
i.Position,
show?.Name ?? "—",
show?.Kind ?? default,
- show?.Audience ?? default,
+ show?.Audience,
show?.EpisodeCount ?? 0,
show?.Year,
show?.PosterImageId
diff --git a/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommand.cs b/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommand.cs
index c402022..1aadd5b 100644
--- a/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommand.cs
+++ b/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommand.cs
@@ -9,5 +9,5 @@ public sealed record CreateShowCommand(
ShowKind Kind,
string? Description,
string? OriginalName = null,
- ShowAudience Audience = ShowAudience.General
+ ShowAudience? Audience = null
) : ICommand>;
diff --git a/backend/src/TeleWave.Application/Library/SetShowAudience/SetShowAudienceCommand.cs b/backend/src/TeleWave.Application/Library/SetShowAudience/SetShowAudienceCommand.cs
index 18042e7..90ff7ca 100644
--- a/backend/src/TeleWave.Application/Library/SetShowAudience/SetShowAudienceCommand.cs
+++ b/backend/src/TeleWave.Application/Library/SetShowAudience/SetShowAudienceCommand.cs
@@ -4,5 +4,5 @@ using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.SetShowAudience;
-/// Задать категорию аудитории шоу (обычное/детское/взрослое).
-public sealed record SetShowAudienceCommand(Guid Id, ShowAudience Audience) : ICommand;
+/// Задать возрастной рейтинг шоу (MPAA); null — снять проставленный.
+public sealed record SetShowAudienceCommand(Guid Id, ShowAudience? Audience) : ICommand;
diff --git a/backend/src/TeleWave.Application/Library/ShowDtos.cs b/backend/src/TeleWave.Application/Library/ShowDtos.cs
index f2f48d1..12628db 100644
--- a/backend/src/TeleWave.Application/Library/ShowDtos.cs
+++ b/backend/src/TeleWave.Application/Library/ShowDtos.cs
@@ -9,7 +9,7 @@ public sealed record ShowSummaryDto(
string Name,
string? OriginalName,
ShowKind Kind,
- ShowAudience Audience,
+ ShowAudience? Audience,
int EpisodeCount,
int SeasonCount,
int? Year,
@@ -42,7 +42,7 @@ public sealed record ShowDto(
string Name,
string? OriginalName,
ShowKind Kind,
- ShowAudience Audience,
+ ShowAudience? Audience,
string? Description,
string? MetadataProvider,
string? MetadataExternalId,
diff --git a/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs b/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs
index 53b492c..2734bb3 100644
--- a/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs
@@ -68,6 +68,12 @@ public sealed class ApplyShowMetadataCommandHandler(
if (genreIds.Count > 0)
show.SetGenres(genreIds);
+ // Рейтинг — по тому же правилу: проставляем только распознанный. «Not Rated», «N/A» и местные
+ // шкалы означают отсутствие данных, и снимать ими уже проставленный рейтинг нельзя — иначе
+ // «обновить метаданные» тихо открыло бы шоу дорогу в детское время.
+ if (ContentRating.Parse(meta.ContentRating) is { } audience)
+ show.SetAudience(audience);
+
return Result.Success();
}
}
diff --git a/backend/src/TeleWave.Application/Metadata/ContentRating.cs b/backend/src/TeleWave.Application/Metadata/ContentRating.cs
new file mode 100644
index 0000000..a69b23d
--- /dev/null
+++ b/backend/src/TeleWave.Application/Metadata/ContentRating.cs
@@ -0,0 +1,69 @@
+using TeleWave.Domain.Library;
+
+namespace TeleWave.Application.Metadata;
+
+///
+/// Приведение возрастного рейтинга из внешнего источника к шкале .
+///
+/// Живёт в приложении, а не в провайдере: таблица соответствий одна на все источники, и разъехавшись
+/// по провайдерам она бы означала, что одно и то же шоу получает разный рейтинг в зависимости от того,
+/// откуда его подтянули.
+///
+/// Незнакомое значение — null, а не догадка. Источники наряду с рейтингами отдают
+/// «Not Rated», «Approved», «Passed», «N/A» и шкалы, которых здесь нет; проставить по ним
+/// хоть что-то значит поставить в детский эфир непроверенное.
+///
+/// Таблица плоская — без указания страны, — и это работает, пока написания не сталкиваются: у нас
+/// это US (PG-13, TV-14) и RU (12+). Появится третья страна — столкновение
+/// почти наверняка будет: Германия и Франция используют голые 12/16/18,
+/// означающие разное. Тогда придётся сделать country-aware, а не дописывать
+/// в таблицу ещё строк.
+///
+public static class ContentRating
+{
+ private static readonly Dictionary Known = new(
+ StringComparer.OrdinalIgnoreCase
+ )
+ {
+ // ── MPAA (полный метр) ──
+ ["G"] = ShowAudience.G,
+ ["PG"] = ShowAudience.Pg,
+ ["PG-13"] = ShowAudience.Pg13,
+ ["R"] = ShowAudience.R,
+ ["NC-17"] = ShowAudience.Nc17,
+
+ // ── TV Parental Guidelines (сериалы). Отдельной шкалой в домене не заводим — по замыслу
+ // рейтинг один, — поэтому приводим к ближайшему по строгости. ──
+ ["TV-Y"] = ShowAudience.G,
+ ["TV-Y7"] = ShowAudience.G,
+ ["TV-Y7-FV"] = ShowAudience.G,
+ ["TV-G"] = ShowAudience.G,
+ ["TV-PG"] = ShowAudience.Pg,
+ ["TV-14"] = ShowAudience.Pg13,
+ ["TV-MA"] = ShowAudience.R,
+
+ // ── Исторические обозначения MPAA: в старом кино IMDb отдаёт именно их. «M» и «GP»
+ // впоследствии стали «PG», «X» — «NC-17». ──
+ ["M"] = ShowAudience.Pg,
+ ["M/PG"] = ShowAudience.Pg,
+ ["GP"] = ShowAudience.Pg,
+ ["X"] = ShowAudience.Nc17,
+ ["AO"] = ShowAudience.Nc17,
+
+ // ── Российская возрастная маркировка (TMDb отдаёт её для RU). Это не перевод, а сближение
+ // по смыслу: шкалы построены по-разному, точного соответствия между ними нет.
+ // 18+ приведён к R, а не к NC-17: в России 18+ носит рядовой боевик, тогда как NC-17 —
+ // редкость, и свалив туда всё 18+ мы обессмыслили бы верх шкалы. ──
+ ["0+"] = ShowAudience.G,
+ ["6+"] = ShowAudience.G,
+ ["12+"] = ShowAudience.Pg,
+ ["16+"] = ShowAudience.Pg13,
+ ["18+"] = ShowAudience.R,
+ };
+
+ /// Рейтинг по написанию источника; null — значение отсутствует либо не распознано.
+ public static ShowAudience? Parse(string? value) =>
+ !string.IsNullOrWhiteSpace(value) && Known.TryGetValue(value.Trim(), out var audience)
+ ? audience
+ : null;
+}
diff --git a/backend/src/TeleWave.Application/Metadata/MetadataModels.cs b/backend/src/TeleWave.Application/Metadata/MetadataModels.cs
index a5ce0e2..0758618 100644
--- a/backend/src/TeleWave.Application/Metadata/MetadataModels.cs
+++ b/backend/src/TeleWave.Application/Metadata/MetadataModels.cs
@@ -1,12 +1,19 @@
+using TeleWave.Domain.Library;
+
namespace TeleWave.Application.Metadata;
-/// Кандидат из поиска по названию в источнике метаданных.
+///
+/// Кандидат из поиска по названию в источнике метаданных. — что нашлось,
+/// сериал или полнометражка (null — источник ищет только по одному типу и различать нечего);
+/// в выдаче, где вперемешку и то и другое, без этого «Фарго» 1996 и «Фарго» 2014 не различить.
+///
public sealed record MetadataCandidate(
string ExternalId,
string Title,
int? Year,
string? Overview,
- string? PosterUrl
+ string? PosterUrl,
+ ShowKind? Kind = null
);
///
@@ -14,6 +21,9 @@ public sealed record MetadataCandidate(
/// — сырые обозначения жанров в том порядке, в каком их отдал источник:
/// идентификаторы (tmdb:28) и названия вперемешку. Сопоставление со справочником —
/// на стороне приложения (см. GenreMatcher); первый распознанный становится основным жанром шоу.
+/// — возрастной рейтинг ровно в том написании, в каком его отдал
+/// источник (PG-13, TV-14, Not Rated); приведение к шкале — тоже на стороне
+/// приложения (см. ContentRating), чтобы таблица соответствий жила в одном месте на все источники.
///
public sealed record ShowMetadata(
string ExternalId,
@@ -21,7 +31,8 @@ public sealed record ShowMetadata(
int? Year,
string? Overview,
string? PosterUrl,
- IReadOnlyList? Genres = null
+ IReadOnlyList? Genres = null,
+ string? ContentRating = null
);
/// Метаданные серии (для этапа 2).
diff --git a/backend/src/TeleWave.Application/Programming/Groups/FindGroupCandidates/FindGroupCandidatesQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/FindGroupCandidates/FindGroupCandidatesQueryHandler.cs
index 7bde2e5..61c8cb4 100644
--- a/backend/src/TeleWave.Application/Programming/Groups/FindGroupCandidates/FindGroupCandidatesQueryHandler.cs
+++ b/backend/src/TeleWave.Application/Programming/Groups/FindGroupCandidates/FindGroupCandidatesQueryHandler.cs
@@ -100,9 +100,12 @@ public sealed class FindGroupCandidatesQueryHandler(
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 <= 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);
diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupElementResolver.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupElementResolver.cs
index 2b91ed1..4e078e9 100644
--- a/backend/src/TeleWave.Application/Programming/Groups/GroupElementResolver.cs
+++ b/backend/src/TeleWave.Application/Programming/Groups/GroupElementResolver.cs
@@ -125,7 +125,7 @@ public sealed class GroupElementResolver(IAppDbContext dbContext)
partAssets.Count,
DurationOf(partAssets),
null,
- // Категория коллекции — самая строгая среди частей: по ней отбирают в детское время.
+ // Рейтинг коллекции — самый строгий среди частей: по нему отбирают в детское время.
parts.Count == 0 ? null : parts.Max(p => p!.Audience),
parts.Count == 0 ? null : parts.Min(p => p!.Year),
collection.PosterImageId
diff --git a/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs b/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs
index 0336386..20a734c 100644
--- a/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs
+++ b/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs
@@ -150,7 +150,7 @@ public sealed class GroupExpander(IAppDbContext dbContext)
.Where(p => p is not null)
.DefaultIfEmpty(null)
.Max(),
- // Категория коллекции — строжайшая среди частей: франшиза идёт целиком,
+ // Рейтинг коллекции — строжайший среди частей: франшиза идёт целиком,
// и одна взрослая часть делает взрослой всю.
partShowIds
.Select(id =>
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs
index c9c7091..917da3b 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs
@@ -245,8 +245,9 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
}
///
- /// Строжайшая категория среди позиций каждой группы: именно она конфликтует с детским временем.
- /// Коллекция берётся по строжайшей части — франшиза идёт целиком.
+ /// Строжайший рейтинг среди позиций каждой группы: именно он конфликтует с детским временем.
+ /// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не
+ /// идут: планировщик их не отсекает, значит и предупреждать не о чем.
///
private async Task> LoadStrictestAudienceAsync(
IReadOnlyCollection groupIds,
@@ -305,7 +306,7 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
foreach (var showId in candidates)
{
- if (!audiences.TryGetValue(showId, out var audience))
+ if (audiences.GetValueOrDefault(showId) is not { } audience)
continue;
if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
result[item.GroupId] = audience;
diff --git a/backend/src/TeleWave.Domain/Library/Show.cs b/backend/src/TeleWave.Domain/Library/Show.cs
index 3ada40d..4497db8 100644
--- a/backend/src/TeleWave.Domain/Library/Show.cs
+++ b/backend/src/TeleWave.Domain/Library/Show.cs
@@ -21,8 +21,9 @@ public class Show
public string? Description { get; private set; }
public ShowKind Kind { get; private set; }
- /// Категория аудитории (обычное/детское/взрослое) — под будущие фильтры показа.
- public ShowAudience Audience { get; private set; }
+ /// Возрастной рейтинг (MPAA) или null, если не проставлен ни источником, ни вручную.
+ /// Шоу без рейтинга планировщик не отсекает: неизвестное не значит «взрослое».
+ public ShowAudience? Audience { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
@@ -52,7 +53,7 @@ public class Show
ShowKind kind,
string? description = null,
string? originalName = null,
- ShowAudience audience = ShowAudience.General
+ ShowAudience? audience = null
) =>
new()
{
@@ -65,8 +66,8 @@ public class Show
CreatedAt = DateTimeOffset.UtcNow,
};
- /// Задать категорию аудитории.
- public void SetAudience(ShowAudience audience) => Audience = audience;
+ /// Задать возрастной рейтинг; null — снять (вернуть в «не проставлен»).
+ public void SetAudience(ShowAudience? audience) => Audience = audience;
/// Основной жанр или null, если жанры не проставлены.
public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId;
diff --git a/backend/src/TeleWave.Domain/Library/ShowAudience.cs b/backend/src/TeleWave.Domain/Library/ShowAudience.cs
index b800308..890b9a6 100644
--- a/backend/src/TeleWave.Domain/Library/ShowAudience.cs
+++ b/backend/src/TeleWave.Domain/Library/ShowAudience.cs
@@ -1,27 +1,43 @@
+using System.Text.Json.Serialization;
+
namespace TeleWave.Domain.Library;
///
-/// Возрастная категория шоу. Значения упорядочены по возрастанию строгости — на этом порядке строятся
-/// правила планировщика вида «до 23:00 не строже подросткового». Новые категории можно вставлять
+/// Возрастной рейтинг шоу по шкале MPAA — той самой, что отдают внешние источники, поэтому значение
+/// из метаданных ложится сюда без выдуманной промежуточной классификации.
+///
+/// Значения упорядочены по возрастанию строгости, и на этом порядке держится весь отбор кандидатов
+/// («до 23:00 не строже » — это обычное сравнение). Новые значения можно вставлять
/// только с сохранением монотонности и с миграцией данных.
///
-/// Это не гейт для зрителя: что показывает канал, то и смотрится. Категория нужна планировщику,
+/// Отсутствие рейтинга — это null, а не значение шкалы: «не проставлено» и «подходит всем» —
+/// разные факты, и загонять их в одну ячейку значит терять оба.
+///
+/// Это не гейт для зрителя: что показывает канал, то и смотрится. Рейтинг нужен планировщику,
/// чтобы не поставить взрослое в детский эфир.
+///
+/// Имена на проводе заданы явно (PG-13, а не Pg13): по ним же приходят данные
+/// источников, и совпадение написания избавляет от таблицы перевода на каждой границе.
///
public enum ShowAudience
{
- /// Детское.
- Kids = 0,
+ /// General Audiences — без ограничений.
+ [JsonStringEnumMemberName("G")]
+ G = 0,
- /// Семейное.
- Family = 1,
+ /// Parental Guidance Suggested — рекомендуется присутствие родителей.
+ [JsonStringEnumMemberName("PG")]
+ Pg = 1,
- /// Подростковое.
- Teen = 2,
+ /// Parents Strongly Cautioned — не рекомендуется детям до 13 лет.
+ [JsonStringEnumMemberName("PG-13")]
+ Pg13 = 2,
- /// Общее — без ограничений (по умолчанию).
- General = 3,
+ /// Restricted — до 17 лет в сопровождении взрослого.
+ [JsonStringEnumMemberName("R")]
+ R = 3,
- /// Взрослое.
- Adult = 4,
+ /// No One 17 and Under Admitted — только для взрослых.
+ [JsonStringEnumMemberName("NC-17")]
+ Nc17 = 4,
}
diff --git a/backend/src/TeleWave.Infrastructure/Metadata/MetadataOptions.cs b/backend/src/TeleWave.Infrastructure/Metadata/MetadataOptions.cs
index a89d373..e7fdd93 100644
--- a/backend/src/TeleWave.Infrastructure/Metadata/MetadataOptions.cs
+++ b/backend/src/TeleWave.Infrastructure/Metadata/MetadataOptions.cs
@@ -18,6 +18,14 @@ public sealed class TmdbOptions
public string ImageBaseUrl { get; init; } = "https://image.tmdb.org/t/p";
public string PosterSize { get; init; } = "w500";
public string StillSize { get; init; } = "w300";
+
+ ///
+ /// Страны сертификации в порядке приоритета: берём первую, для которой у шоу есть рейтинг.
+ /// US впереди намеренно — его шкала совпадает с нашей и переводится точно, тогда как RU
+ /// (0+…18+) приводится приблизительно (см. ContentRating) и нужен ради покрытия там,
+ /// где американской сертификации у контента нет и не было.
+ ///
+ public IReadOnlyList CertificationCountries { get; init; } = ["US", "RU"];
}
public sealed class OmdbOptions
diff --git a/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs b/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs
index a210725..c171011 100644
--- a/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs
+++ b/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs
@@ -3,6 +3,7 @@ using System.Text.Json;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Metadata;
+using TeleWave.Domain.Library;
using static TeleWave.Infrastructure.Metadata.MetadataJson;
namespace TeleWave.Infrastructure.Metadata;
@@ -22,8 +23,10 @@ public sealed class OmdbMetadataProvider(
CancellationToken cancellationToken
)
{
- var url =
- $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&type=series&s={Uri.EscapeDataString(query)}";
+ // Тип в запросе не фиксируем: imdbID уникален и для фильма, и для сериала, а GetShowAsync
+ // ходит по нему же — значит полнометражки находятся и обогащаются тем же путём, без
+ // разделения пространств идентификаторов (в отличие от TMDb, где tv и movie нумеруются врозь).
+ var url = $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&s={Uri.EscapeDataString(query)}";
using var doc = await GetAsync(httpFactory, url, cancellationToken);
if (!doc.RootElement.TryGetProperty("Search", out var search))
return [];
@@ -34,13 +37,20 @@ public sealed class OmdbMetadataProvider(
var id = Clean(GetString(item, "imdbID"));
if (id is null)
continue;
+
+ // Отдельные серии в выдаче — шум: шоу из них не собирают, а список они забивают.
+ var kind = KindFrom(Clean(GetString(item, "Type")));
+ if (kind is null)
+ continue;
+
list.Add(
new MetadataCandidate(
id,
Clean(GetString(item, "Title")) ?? "—",
YearFrom(GetString(item, "Year")),
null,
- Clean(GetString(item, "Poster"))
+ Clean(GetString(item, "Poster")),
+ kind
)
);
}
@@ -63,10 +73,22 @@ public sealed class OmdbMetadataProvider(
YearFrom(GetString(root, "Year")),
Clean(GetString(root, "Plot")),
Clean(GetString(root, "Poster")),
- GenresFrom(Clean(GetString(root, "Genre")))
+ GenresFrom(Clean(GetString(root, "Genre"))),
+ // «Rated» приезжает только в ответе по id — то есть ровно здесь, без лишнего запроса.
+ // Отдаём как есть: приводить к шкале будет приложение (ContentRating).
+ Clean(GetString(root, "Rated"))
);
}
+ /// Тип из выдачи OMDb: «series»/«movie»; «episode» и незнакомое — null (в шоу не годятся).
+ private static ShowKind? KindFrom(string? type) =>
+ type?.ToLowerInvariant() switch
+ {
+ "series" => ShowKind.Series,
+ "movie" => ShowKind.Single,
+ _ => null,
+ };
+
/// OMDb отдаёт жанры одной строкой через запятую: «Action, Sci-Fi».
private static List GenresFrom(string? value) =>
string.IsNullOrWhiteSpace(value)
diff --git a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs
index 0dd580c..f72bfb8 100644
--- a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs
+++ b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs
@@ -3,6 +3,7 @@ using System.Text.Json;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Metadata;
+using TeleWave.Domain.Library;
using static TeleWave.Infrastructure.Metadata.MetadataJson;
namespace TeleWave.Infrastructure.Metadata;
@@ -43,7 +44,10 @@ public sealed class TmdbMetadataProvider(
GetString(item, "name") ?? "—",
YearFrom(GetString(item, "first_air_date")),
GetString(item, "overview"),
- PosterUrl(GetString(item, "poster_path"))
+ PosterUrl(GetString(item, "poster_path")),
+ // Поиск идёт по /search/tv, поэтому здесь всегда сериалы: полнометражки TMDb
+ // живут в отдельном пространстве идентификаторов и пока не поддержаны.
+ ShowKind.Series
)
);
}
@@ -55,8 +59,10 @@ public sealed class TmdbMetadataProvider(
CancellationToken cancellationToken
)
{
+ // content_ratings подвешиваем к тому же запросу — отдельного обращения к API не требуется.
var url =
- $"{Tmdb.BaseUrl}/tv/{externalId}?api_key={Tmdb.ApiKey}&language={_options.Language}";
+ $"{Tmdb.BaseUrl}/tv/{externalId}?api_key={Tmdb.ApiKey}&language={_options.Language}"
+ + "&append_to_response=content_ratings";
using var doc = await TryGetAsync(httpFactory, url, cancellationToken);
if (doc is null)
return null;
@@ -67,10 +73,49 @@ public sealed class TmdbMetadataProvider(
YearFrom(GetString(root, "first_air_date")),
GetString(root, "overview"),
PosterUrl(GetString(root, "poster_path")),
- GenresFrom(root)
+ GenresFrom(root),
+ CertificationFrom(root)
);
}
+ ///
+ /// Возрастная сертификация из content_ratings: перебираем страны в порядке приоритета
+ /// () и берём первую непустую. Отдаём написание
+ /// источника как есть — приводит его к шкале приложение (ContentRating).
+ ///
+ /// Пустая строка в rating — обычное дело: страна в списке есть, а рейтинга у неё нет,
+ /// и такую запись надо пропускать, а не считать ответом.
+ ///
+ private string? CertificationFrom(JsonElement root)
+ {
+ if (
+ !root.TryGetProperty("content_ratings", out var ratings)
+ || !ratings.TryGetProperty("results", out var results)
+ || results.ValueKind != JsonValueKind.Array
+ )
+ return null;
+
+ foreach (var country in Tmdb.CertificationCountries)
+ {
+ foreach (var item in results.EnumerateArray())
+ {
+ if (
+ !string.Equals(
+ GetString(item, "iso_3166_1"),
+ country,
+ StringComparison.OrdinalIgnoreCase
+ )
+ )
+ continue;
+
+ if (GetString(item, "rating") is { Length: > 0 } rating && rating.Trim().Length > 0)
+ return rating.Trim();
+ }
+ }
+
+ return null;
+ }
+
///
/// Жанры из ответа TMDb в порядке источника: на каждый — сначала идентификатор
/// (tmdb:28), затем локализованное название. Идентификатор точнее, название — запасной
diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726190050_ShowAudienceMpaa.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726190050_ShowAudienceMpaa.Designer.cs
new file mode 100644
index 0000000..d5e1ea8
--- /dev/null
+++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726190050_ShowAudienceMpaa.Designer.cs
@@ -0,0 +1,1389 @@
+//
+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("20260726190050_ShowAudienceMpaa")]
+ partial class ShowAudienceMpaa
+ {
+ ///
+ 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("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("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