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("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.HasOne("TeleWave.Domain.Library.Collection", null) + .WithMany("Items") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany() + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.HasOne("TeleWave.Domain.Programming.ScheduleTemplate", null) + .WithMany("Layers") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.JunctionTemplate", null) + .WithMany("Elements") + .HasForeignKey("JunctionTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.GridLayer", null) + .WithMany("Slots") + .HasForeignKey("LayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.HasOne("TeleWave.Domain.Programming.Slot", null) + .WithOne() + .HasForeignKey("TeleWave.Domain.Programming.SlotState", "SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("BumperTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Navigation("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Navigation("Elements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Navigation("Layers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726190050_ShowAudienceMpaa.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726190050_ShowAudienceMpaa.cs new file mode 100644 index 0000000..dee7790 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726190050_ShowAudienceMpaa.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class ShowAudienceMpaa : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Audience", + table: "Shows", + type: "integer", + nullable: true, + oldClrType: typeof(int), + oldType: "integer"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Audience", + table: "Shows", + type: "integer", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "integer", + oldNullable: true); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 7a320a6..dbbdee8 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -575,7 +575,7 @@ namespace TeleWave.Infrastructure.Migrations b.Property("Id") .HasColumnType("uuid"); - b.Property("Audience") + b.Property("Audience") .HasColumnType("integer"); b.Property("CreatedAt") diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/ContentRatingTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/ContentRatingTests.cs new file mode 100644 index 0000000..2dc0b1c --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Metadata/ContentRatingTests.cs @@ -0,0 +1,71 @@ +using TeleWave.Application.Metadata; +using TeleWave.Domain.Library; +using Xunit; + +namespace TeleWave.Application.Tests.Metadata; + +public class ContentRatingTests +{ + [Theory] + [InlineData("G", ShowAudience.G)] + [InlineData("PG", ShowAudience.Pg)] + [InlineData("PG-13", ShowAudience.Pg13)] + [InlineData("R", ShowAudience.R)] + [InlineData("NC-17", ShowAudience.Nc17)] + public void Parse_Mpaa(string value, ShowAudience expected) => + Assert.Equal(expected, ContentRating.Parse(value)); + + [Theory] + [InlineData("TV-Y", ShowAudience.G)] + [InlineData("TV-Y7", ShowAudience.G)] + [InlineData("TV-G", ShowAudience.G)] + [InlineData("TV-PG", ShowAudience.Pg)] + [InlineData("TV-14", ShowAudience.Pg13)] + [InlineData("TV-MA", ShowAudience.R)] + public void Parse_TvGuidelines_MapsToNearest(string value, ShowAudience expected) => + Assert.Equal(expected, ContentRating.Parse(value)); + + [Theory] + [InlineData("0+", ShowAudience.G)] + [InlineData("6+", ShowAudience.G)] + [InlineData("12+", ShowAudience.Pg)] + [InlineData("16+", ShowAudience.Pg13)] + [InlineData("18+", ShowAudience.R)] + public void Parse_RussianMarking_MapsApproximately(string value, ShowAudience expected) => + Assert.Equal(expected, ContentRating.Parse(value)); + + [Theory] + [InlineData("M", ShowAudience.Pg)] + [InlineData("GP", ShowAudience.Pg)] + [InlineData("X", ShowAudience.Nc17)] + public void Parse_HistoricalCodes(string value, ShowAudience expected) => + Assert.Equal(expected, ContentRating.Parse(value)); + + [Theory] + [InlineData("pg-13", ShowAudience.Pg13)] + [InlineData(" R ", ShowAudience.R)] + public void Parse_IgnoresCaseAndPadding(string value, ShowAudience expected) => + Assert.Equal(expected, ContentRating.Parse(value)); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("N/A")] + [InlineData("Not Rated")] + [InlineData("Unrated")] + [InlineData("Approved")] + [InlineData("Passed")] + // Голые числа — это немецкая FSK и французская шкала, которых мы не поддерживаем: «16» там + // означает не то же, что российское «16+», и принимать их за него нельзя. + [InlineData("16")] + [InlineData("12")] + [InlineData("какая-то ерунда")] + public void Parse_UnknownOrAbsent_IsNull(string? value) + { + // Незнакомое значение не должно превращаться в догадку: по нему шоу попало бы в детский эфир + // непроверенным. Отдельно проверяем «Approved»/«Passed» — исторические пометки IMDb, + // означающие «цензуру прошёл», а не «подходит детям». + Assert.Null(ContentRating.Parse(value)); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/PlanningRulesTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/PlanningRulesTests.cs index 14ddd1f..b780d45 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/PlanningRulesTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/PlanningRulesTests.cs @@ -16,12 +16,12 @@ public class PlanningRulesTests public void AudienceAt_DayWindow(int hour, int minute, bool inside) { var rules = new PlanningRules( - [new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(23, 0), ShowAudience.Teen)] + [new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(23, 0), ShowAudience.Pg13)] ); var result = rules.AudienceAt(new TimeOnly(hour, minute)); - Assert.Equal(inside ? ShowAudience.Teen : null, result); + Assert.Equal(inside ? ShowAudience.Pg13 : null, result); } [Theory] @@ -32,12 +32,12 @@ public class PlanningRulesTests { // «С 23:00 до 06:00» — ночное окно, границы сравниваются в обратную сторону. var rules = new PlanningRules( - [new AudienceWindow(new TimeOnly(23, 0), new TimeOnly(6, 0), ShowAudience.Adult)] + [new AudienceWindow(new TimeOnly(23, 0), new TimeOnly(6, 0), ShowAudience.Nc17)] ); var result = rules.AudienceAt(new TimeOnly(hour, minute)); - Assert.Equal(inside ? ShowAudience.Adult : null, result); + Assert.Equal(inside ? ShowAudience.Nc17 : null, result); } [Fact] @@ -46,13 +46,13 @@ public class PlanningRulesTests // Широкое окно, случайно наложенное поверх детского, не должно его отменять. var rules = new PlanningRules( [ - new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(23, 0), ShowAudience.General), - new AudienceWindow(new TimeOnly(7, 0), new TimeOnly(10, 0), ShowAudience.Kids), + new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(23, 0), ShowAudience.R), + new AudienceWindow(new TimeOnly(7, 0), new TimeOnly(10, 0), ShowAudience.G), ] ); - Assert.Equal(ShowAudience.Kids, rules.AudienceAt(new TimeOnly(8, 0))); - Assert.Equal(ShowAudience.General, rules.AudienceAt(new TimeOnly(12, 0))); + Assert.Equal(ShowAudience.G, rules.AudienceAt(new TimeOnly(8, 0))); + Assert.Equal(ShowAudience.R, rules.AudienceAt(new TimeOnly(12, 0))); } [Fact] @@ -65,7 +65,7 @@ public class PlanningRulesTests public void Json_RoundTrips() { var rules = new PlanningRules( - [new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(23, 0), ShowAudience.Teen)], + [new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(23, 0), ShowAudience.Pg13)], new RepeatLimitRule(7, 2), MaxBreakMinutesPerHour: 12, MaxGenreSharePercent: 40, @@ -84,6 +84,19 @@ public class PlanningRulesTests Assert.Equal(rules.MaxFallbackSharePercent, restored.MaxFallbackSharePercent); } + [Fact] + public void Json_WritesMpaaSpelling() + { + // Рейтинг уезжает в jsonb шаблона и в API ровно тем написанием, каким приходит от источников. + // Round-trip этого не поймает: он одинаково зелёный и на «Pg13», а такое значение потом + // придётся переводить на каждой границе. + var json = new PlanningRules( + [new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(23, 0), ShowAudience.Pg13)] + ).ToJson(); + + Assert.Contains("\"PG-13\"", json, StringComparison.Ordinal); + } + [Fact] public void FromJson_Garbage_IsNull() { diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/CandidateFilterTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/CandidateFilterTests.cs index 3b49e8b..b010ee3 100644 --- a/backend/tests/TeleWave.Domain.Tests/Programming/CandidateFilterTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Programming/CandidateFilterTests.cs @@ -66,23 +66,23 @@ public class CandidateFilterTests [Fact] public void Audience_FiltersOutStricterContent() { - var kids = Element(ShowAudience.Kids); - var adult = Element(ShowAudience.Adult, position: 1); + var forEveryone = Element(ShowAudience.G); + var adult = Element(ShowAudience.Nc17, position: 1); var pick = ElementSelector.Select( - Slot([adult, kids], maxAudience: ShowAudience.Family), + Slot([adult, forEveryone], maxAudience: ShowAudience.Pg), T0, new FirstAlways() ); Assert.NotNull(pick); - Assert.Equal(kids.ElementId, pick.Element.ElementId); + Assert.Equal(forEveryone.ElementId, pick.Element.ElementId); } [Fact] public void Audience_WithoutLimit_KeepsEverything() { - var adult = Element(ShowAudience.Adult); + var adult = Element(ShowAudience.Nc17); var pick = ElementSelector.Select(Slot([adult]), T0, new FirstAlways()); @@ -91,13 +91,13 @@ public class CandidateFilterTests } [Fact] - public void Audience_UnknownCategory_IsNotDropped() + public void Audience_UnknownRating_IsNotDropped() { - // Неизвестная категория — не повод выбрасывать: иначе контент исчезал бы из эфира молча. + // Непроставленный рейтинг — не повод выбрасывать: иначе контент исчезал бы из эфира молча. var unknown = Element(audience: null); var pick = ElementSelector.Select( - Slot([unknown], maxAudience: ShowAudience.Kids), + Slot([unknown], maxAudience: ShowAudience.G), T0, new FirstAlways() ); @@ -110,7 +110,7 @@ public class CandidateFilterTests public void Audience_AllFilteredOut_ReturnsNothing() { var pick = ElementSelector.Select( - Slot([Element(ShowAudience.Adult)], maxAudience: ShowAudience.Kids), + Slot([Element(ShowAudience.Nc17)], maxAudience: ShowAudience.G), T0, new FirstAlways() ); @@ -121,11 +121,11 @@ public class CandidateFilterTests [Fact] public void Audience_AppliesToSequentialStrategyToo() { - var adult = Element(ShowAudience.Adult, position: 0); - var teen = Element(ShowAudience.Teen, position: 1); + var adult = Element(ShowAudience.Nc17, position: 0); + var teen = Element(ShowAudience.Pg13, position: 1); var pick = ElementSelector.Select( - Slot([adult, teen], maxAudience: ShowAudience.Teen, strategy: SlotStrategyKind.Sequential), + Slot([adult, teen], maxAudience: ShowAudience.Pg13, strategy: SlotStrategyKind.Sequential), T0, new FirstAlways() ); diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index cb461c5..7a52ada 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -28,7 +28,7 @@ канал создаётся с нуля, но так, чтобы это было приятно. Возрастных ограничений на стороне зрителя нет. Что показывает канал — то и смотришь, как и было. -Возрастная категория — инструмент планирования (не поставить взрослое в детский эфир), а не гейт. +Возрастной рейтинг — инструмент планирования (не поставить взрослое в детский эфир), а не гейт. ### 1.1. От чего уходим @@ -128,19 +128,25 @@ ShowGenre — многие-ко-многим Отдельных свободных тегов не заводим: их роль («ретро», «новогоднее») выполняют группы с ручным составом, а это одна сущность вместо двух. -**Возрастная категория.** `ShowAudience` расширяется до пяти значений, **упорядоченных по возрастанию -строгости** — правила формулируются как «до 23:00 не строже подросткового», поэтому порядок в enum -значим: +**Возрастной рейтинг.** `ShowAudience` — шкала MPAA, **упорядоченная по возрастанию строгости**: +правила формулируются как «до 23:00 не строже PG-13», поэтому порядок в enum значим. ``` -Kids — детское -Family — семейное -Teen — подростковое -General — общее -Adult — взрослое +G — без ограничений +PG — с родителями +PG-13 — не рекомендуется до 13 +R — до 17 со взрослым +NC-17 — только взрослым ``` -Текущие три значения (`General=0, Kids=1, Adult=2`) не отсортированы и переписываются. +Шкала взята не абстрактная, а ровно та, которой оперируют внешние источники (`Rated` у OMDb, +сертификации у TMDb): рейтинг из метаданных ложится в поле без промежуточной классификации, +которую пришлось бы придумывать и объяснять. + +**Отсутствие рейтинга — `null`, а не значение шкалы.** «Не проставлен» и «подходит всем» — разные +факты; в одной ячейке теряются оба. Контент без рейтинга планировщик **не отсекает**: источники +проставляют рейтинг далеко не всему (на неамериканском кино чаще всего не проставляют вовсе), +и трактовка «неизвестное значит взрослое» вымела бы из эфира большую часть библиотеки. **Коллекция** — франшиза: упорядоченный набор шоу. «Терминатор» — это три `Show(Single)` в порядке 1→2→3. Живёт в библиотеке рядом с шоу, потому что «одно произведение из трёх частей» — факт о контенте, @@ -209,7 +215,7 @@ GroupItem "elementKinds": ["show", "collection"], "showKinds": ["series", "single"], "genres": { "anyOf": ["боевик", "триллер"] }, - "audience": { "max": "Teen" }, + "audience": { "max": "PG-13" }, "year": { "min": 1989, "max": 1999 }, "unitDurationMinutes": { "min": 20, "max": 25 } } @@ -476,7 +482,7 @@ ffmpeg с кэшем по паре шоу, `ScheduleBumperResolver` — вся | Правило | Параметры | Смысл | |---------|-----------|-------| -| `maxAudienceByTime` | `{from, to, maxAudience}` | Детское время: до 23:00 не строже подросткового | +| `maxAudienceByTime` | `{from, to, maxAudience}` | Детское время: до 23:00 не строже `PG-13` | | `cooldown` | `{days}` | Не показывать элемент, игравший последние N дней | | `maxRepeatsInWindow` | `{windowDays, max}` | Не чаще N раз за период | @@ -654,7 +660,7 @@ seed = hash(channelId, date, slotId, occurrenceInDay) | Дыра в сетке | интервал не покрыт даже фоном | «Не покрыто: вт 03:00–06:00» | | Пересечение слотов | overlap внутри одного слоя | «Слоты пересекаются» | | Недостижимый кулдаун | `cooldownDays × выходов в день > itemCount` | «Остывание 28 дней невыполнимо при 3 позициях» | -| Возрастной конфликт | группа содержит строже, чем позволяет окно | «В группе есть 18+, слот стоит в детском времени» | +| Возрастной конфликт | группа содержит строже, чем позволяет окно | «В группе есть `NC-17`, слот стоит в детском времени» | Запас группы показывается прямо в инспекторе слота: «342 позиции · 118 ч · полный цикл 6.2 недели». diff --git a/frontend/src/features/admin/channels/components/RulesCard.tsx b/frontend/src/features/admin/channels/components/RulesCard.tsx index d33fa61..6be588c 100644 --- a/frontend/src/features/admin/channels/components/RulesCard.tsx +++ b/frontend/src/features/admin/channels/components/RulesCard.tsx @@ -15,7 +15,7 @@ import { Label } from '@/shared/ui/label' import { updateTemplate } from '../api' import { CollapsibleCard } from './CollapsibleCard' -const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'Teen' } +const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'PG-13' } /** * Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие diff --git a/frontend/src/features/admin/shows/ShowDetail.tsx b/frontend/src/features/admin/shows/ShowDetail.tsx index f7685c9..0f0b3f5 100644 --- a/frontend/src/features/admin/shows/ShowDetail.tsx +++ b/frontend/src/features/admin/shows/ShowDetail.tsx @@ -4,7 +4,12 @@ import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { ChevronLeft } from 'lucide-react' import { qk } from '@/shared/api/query-keys' -import { SHOW_AUDIENCES, type MediaAssetDto, type ShowAudience } from '@/shared/api/types' +import { + AUDIENCE_UNSET, + SHOW_AUDIENCES, + type MediaAssetDto, + type ShowAudience, +} from '@/shared/api/types' import { useApiError } from '@/shared/lib/use-api-error' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' @@ -51,7 +56,7 @@ export function ShowDetail({ showId }: { showId: string }) { const onError = useApiError() const audienceMutation = useMutation({ - mutationFn: (audience: ShowAudience) => setShowAudience(showId, audience), + mutationFn: (audience: ShowAudience | null) => setShowAudience(showId, audience), onSuccess: invalidate, onError, }) @@ -150,13 +155,16 @@ export function ShowDetail({ showId }: { showId: string }) {

{show.name}

{t(`admin.shows.kinds.${show.kind}`)} - setAudience(v === AUDIENCE_UNSET ? null : (v as ShowAudience))} + > + + {t('admin.shows.audienceUnset')} {SHOW_AUDIENCES.map((value) => ( {t(`admin.shows.audiences.${value}`)} @@ -224,7 +235,13 @@ export function ShowsPanel() { {t(`admin.shows.kinds.${show.kind}`)} - {t(`admin.shows.audiences.${show.audience}`)} + {/* В таблице показываем сам код MPAA: он короткий и одинаков во всех локалях, + а расшифровка есть в селекте на карточке шоу. */} + {show.audience ? ( + {show.audience} + ) : ( + + )} {show.primaryGenre ?? '—'} {show.seasonCount} diff --git a/frontend/src/features/admin/shows/api.ts b/frontend/src/features/admin/shows/api.ts index f847296..318b3e8 100644 --- a/frontend/src/features/admin/shows/api.ts +++ b/frontend/src/features/admin/shows/api.ts @@ -23,12 +23,13 @@ export function createShow(body: { kind: ShowKind description?: string originalName?: string - audience?: ShowAudience + audience?: ShowAudience | null }) { return apiRequest('/admin/shows', { method: 'POST', body }) } -export function setShowAudience(id: string, audience: ShowAudience) { +/** `null` — снять проставленный рейтинг. */ +export function setShowAudience(id: string, audience: ShowAudience | null) { return apiRequest(`/admin/shows/${id}/audience`, { method: 'PUT', body: { audience } }) } diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 6cba80a..312545f 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -128,18 +128,27 @@ export type GenreDto = { /** `Interstitial` — ролик-врезка: то же шоу, но со своим экраном и вне общей библиотеки. */ export type ShowKind = 'Series' | 'Single' | 'Interstitial' -/** Возрастная категория, по возрастанию строгости — порядок значим для правил планировщика. */ -export type ShowAudience = 'Kids' | 'Family' | 'Teen' | 'General' | 'Adult' +/** + * Возрастной рейтинг (MPAA), по возрастанию строгости — порядок значим для правил планировщика. + * `null` у шоу означает «не проставлен», а не «подходит всем»: такой контент планировщик не отсекает. + */ +export type ShowAudience = 'G' | 'PG' | 'PG-13' | 'R' | 'NC-17' /** Тот же порядок для селектов и списков. */ -export const SHOW_AUDIENCES: ShowAudience[] = ['Kids', 'Family', 'Teen', 'General', 'Adult'] +export const SHOW_AUDIENCES: ShowAudience[] = ['G', 'PG', 'PG-13', 'R', 'NC-17'] + +/** + * Значение пункта «рейтинг не проставлен» в селектах. Пустая строка не годится: Radix резервирует её + * под сброс и на пункте с value="" падает. + */ +export const AUDIENCE_UNSET = 'unset' export type ShowSummaryDto = { id: string name: string originalName: string | null kind: ShowKind - audience: ShowAudience + audience: ShowAudience | null episodeCount: number seasonCount: number year: number | null @@ -192,7 +201,7 @@ type CollectionItemDto = { position: number showName: string showKind: ShowKind - showAudience: ShowAudience + showAudience: ShowAudience | null episodeCount: number year: number | null posterImageId: string | null @@ -286,6 +295,8 @@ export type MetadataCandidate = { year: number | null overview: string | null posterUrl: string | null + /** Сериал или полнометражка; null — источник ищет только по одному типу. */ + kind: ShowKind | null } type SeasonGapDto = { @@ -327,7 +338,7 @@ export type ShowDto = { name: string originalName: string | null kind: ShowKind - audience: ShowAudience + audience: ShowAudience | null description: string | null metadataProvider: string | null metadataExternalId: string | null diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index efef537..deb0c10 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -108,7 +108,8 @@ export const en = { genres: 'Genres', genresHint: 'Any of the checked ones.', maxAudience: 'No stricter than', - audienceHint: 'Categories are ordered by strictness: kids → family → … → adult.', + audienceHint: + 'Ratings are ordered by strictness: G → PG → PG-13 → R → NC-17. Unrated shows stay in the selection.', year: 'Year', unitMinutes: 'Unit runtime, min', unitMinutesHint: @@ -294,13 +295,14 @@ export const en = { originalName: 'Original name (eng)', kind: 'Kind', kinds: { Series: 'Series', Single: 'Movie', Interstitial: 'Clip' }, - audience: 'Category', + audience: 'Rating', + audienceUnset: 'Not rated', audiences: { - Kids: 'Kids', - Family: 'Family', - Teen: 'Teen', - General: 'General', - Adult: 'Adult', + G: 'G — general audiences', + PG: 'PG — parental guidance', + 'PG-13': 'PG-13 — over 13', + R: 'R — under 17 with adult', + 'NC-17': 'NC-17 — adults only', }, seasons: 'Seasons', loadedSeasons: 'Loaded seasons', diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index e87d70b..85e56e2 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -108,7 +108,8 @@ export const ru = { genres: 'Жанры', genresHint: 'Любой из отмеченных.', maxAudience: 'Возраст не строже', - audienceHint: 'Категории упорядочены по строгости: детское → семейное → … → взрослое.', + audienceHint: + 'Рейтинги упорядочены по строгости: G → PG → PG-13 → R → NC-17. Шоу без рейтинга остаются в выборке.', year: 'Год', unitMinutes: 'Длительность единицы, мин', unitMinutesHint: @@ -294,13 +295,14 @@ export const ru = { originalName: 'Оригинальное название (eng)', kind: 'Тип', kinds: { Series: 'Сериал', Single: 'Полнометражка', Interstitial: 'Ролик' }, - audience: 'Категория', + audience: 'Рейтинг', + audienceUnset: 'Не проставлен', audiences: { - Kids: 'Детское', - Family: 'Семейное', - Teen: 'Подростковое', - General: 'Общее', - Adult: 'Взрослое', + G: 'G — без ограничений', + PG: 'PG — с родителями', + 'PG-13': 'PG-13 — с 13 лет', + R: 'R — с 17 со взрослым', + 'NC-17': 'NC-17 — только взрослым', }, seasons: 'Сезоны', loadedSeasons: 'Загружены сезоны',