Refactor ShowAudience handling across the application to align with MPAA rating system. Update ShowAudience enum to reflect new ratings (G, PG, PG-13, R, NC-17) and adjust related data models, API types, and frontend components to support nullable audience values. Enhance metadata handling to incorporate content ratings from external sources, ensuring proper audience filtering in scheduling logic. Update documentation to clarify changes in audience categorization and its implications for content management.
ci / build-backend (push) Successful in 1m28s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 2m7s
ci / sonar (push) Successful in 4m31s

This commit is contained in:
Leonid Pershin
2026-07-26 22:18:14 +03:00
parent c4c93cff02
commit fdb0f321e4
34 changed files with 1868 additions and 116 deletions
@@ -177,6 +177,7 @@ public sealed record RenameShowBody(string Name);
public sealed record SetShowOriginalNameBody(string? OriginalName);
public sealed record SetShowAudienceBody(ShowAudience Audience);
/// <summary>Рейтинг шоу; null — снять проставленный.</summary>
public sealed record SetShowAudienceBody(ShowAudience? Audience);
public sealed record SetShowGenresBody(IReadOnlyList<Guid> GenreIds, Guid? PrimaryGenreId);
@@ -20,7 +20,7 @@ public sealed record CollectionItemDto(
int Position,
string ShowName,
ShowKind ShowKind,
ShowAudience ShowAudience,
ShowAudience? ShowAudience,
int EpisodeCount,
int? Year,
Guid? PosterImageId
@@ -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
@@ -9,5 +9,5 @@ public sealed record CreateShowCommand(
ShowKind Kind,
string? Description,
string? OriginalName = null,
ShowAudience Audience = ShowAudience.General
ShowAudience? Audience = null
) : ICommand<Result<Guid>>;
@@ -4,5 +4,5 @@ using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.SetShowAudience;
/// <summary>Задать категорию аудитории шоу (обычное/детское/взрослое).</summary>
public sealed record SetShowAudienceCommand(Guid Id, ShowAudience Audience) : ICommand<Result>;
/// <summary>Задать возрастной рейтинг шоу (MPAA); null — снять проставленный.</summary>
public sealed record SetShowAudienceCommand(Guid Id, ShowAudience? Audience) : ICommand<Result>;
@@ -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,
@@ -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();
}
}
@@ -0,0 +1,69 @@
using TeleWave.Domain.Library;
namespace TeleWave.Application.Metadata;
/// <summary>
/// Приведение возрастного рейтинга из внешнего источника к шкале <see cref="ShowAudience"/>.
///
/// Живёт в приложении, а не в провайдере: таблица соответствий одна на все источники, и разъехавшись
/// по провайдерам она бы означала, что одно и то же шоу получает разный рейтинг в зависимости от того,
/// откуда его подтянули.
///
/// Незнакомое значение — <c>null</c>, а не догадка. Источники наряду с рейтингами отдают
/// «Not Rated», «Approved», «Passed», «N/A» и шкалы, которых здесь нет; проставить по ним
/// хоть что-то значит поставить в детский эфир непроверенное.
///
/// Таблица плоская — без указания страны, — и это работает, пока написания не сталкиваются: у нас
/// это US (<c>PG-13</c>, <c>TV-14</c>) и RU (<c>12+</c>). Появится третья страна — столкновение
/// почти наверняка будет: Германия и Франция используют голые <c>12</c>/<c>16</c>/<c>18</c>,
/// означающие разное. Тогда <see cref="Parse"/> придётся сделать country-aware, а не дописывать
/// в таблицу ещё строк.
/// </summary>
public static class ContentRating
{
private static readonly Dictionary<string, ShowAudience> 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,
};
/// <summary>Рейтинг по написанию источника; null — значение отсутствует либо не распознано.</summary>
public static ShowAudience? Parse(string? value) =>
!string.IsNullOrWhiteSpace(value) && Known.TryGetValue(value.Trim(), out var audience)
? audience
: null;
}
@@ -1,12 +1,19 @@
using TeleWave.Domain.Library;
namespace TeleWave.Application.Metadata;
/// <summary>Кандидат из поиска по названию в источнике метаданных.</summary>
/// <summary>
/// Кандидат из поиска по названию в источнике метаданных. <paramref name="Kind"/> — что нашлось,
/// сериал или полнометражка (null — источник ищет только по одному типу и различать нечего);
/// в выдаче, где вперемешку и то и другое, без этого «Фарго» 1996 и «Фарго» 2014 не различить.
/// </summary>
public sealed record MetadataCandidate(
string ExternalId,
string Title,
int? Year,
string? Overview,
string? PosterUrl
string? PosterUrl,
ShowKind? Kind = null
);
/// <summary>
@@ -14,6 +21,9 @@ public sealed record MetadataCandidate(
/// <paramref name="Genres"/> — сырые обозначения жанров в том порядке, в каком их отдал источник:
/// идентификаторы (<c>tmdb:28</c>) и названия вперемешку. Сопоставление со справочником —
/// на стороне приложения (см. GenreMatcher); первый распознанный становится основным жанром шоу.
/// <paramref name="ContentRating"/> — возрастной рейтинг ровно в том написании, в каком его отдал
/// источник (<c>PG-13</c>, <c>TV-14</c>, <c>Not Rated</c>); приведение к шкале — тоже на стороне
/// приложения (см. ContentRating), чтобы таблица соответствий жила в одном месте на все источники.
/// </summary>
public sealed record ShowMetadata(
string ExternalId,
@@ -21,7 +31,8 @@ public sealed record ShowMetadata(
int? Year,
string? Overview,
string? PosterUrl,
IReadOnlyList<string>? Genres = null
IReadOnlyList<string>? Genres = null,
string? ContentRating = null
);
/// <summary>Метаданные серии (для этапа 2).</summary>
@@ -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);
@@ -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
@@ -150,7 +150,7 @@ public sealed class GroupExpander(IAppDbContext dbContext)
.Where(p => p is not null)
.DefaultIfEmpty(null)
.Max(),
// Категория коллекции — строжайшая среди частей: франшиза идёт целиком,
// Рейтинг коллекции — строжайший среди частей: франшиза идёт целиком,
// и одна взрослая часть делает взрослой всю.
partShowIds
.Select(id =>
@@ -245,8 +245,9 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
}
/// <summary>
/// Строжайшая категория среди позиций каждой группы: именно она конфликтует с детским временем.
/// Коллекция берётся по строжайшей части — франшиза идёт целиком.
/// Строжайший рейтинг среди позиций каждой группы: именно он конфликтует с детским временем.
/// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не
/// идут: планировщик их не отсекает, значит и предупреждать не о чем.
/// </summary>
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
IReadOnlyCollection<Guid> 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;
+6 -5
View File
@@ -21,8 +21,9 @@ public class Show
public string? Description { get; private set; }
public ShowKind Kind { get; private set; }
/// <summary>Категория аудитории (обычное/детское/взрослое) — под будущие фильтры показа.</summary>
public ShowAudience Audience { get; private set; }
/// <summary>Возрастной рейтинг (MPAA) или null, если не проставлен ни источником, ни вручную.
/// Шоу без рейтинга планировщик не отсекает: неизвестное не значит «взрослое».</summary>
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,
};
/// <summary>Задать категорию аудитории.</summary>
public void SetAudience(ShowAudience audience) => Audience = audience;
/// <summary>Задать возрастной рейтинг; null — снять (вернуть в «не проставлен»).</summary>
public void SetAudience(ShowAudience? audience) => Audience = audience;
/// <summary>Основной жанр или null, если жанры не проставлены.</summary>
public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId;
@@ -1,27 +1,43 @@
using System.Text.Json.Serialization;
namespace TeleWave.Domain.Library;
/// <summary>
/// Возрастная категория шоу. Значения упорядочены по возрастанию строгости — на этом порядке строятся
/// правила планировщика вида «до 23:00 не строже подросткового». Новые категории можно вставлять
/// Возрастной рейтинг шоу по шкале MPAA — той самой, что отдают внешние источники, поэтому значение
/// из метаданных ложится сюда без выдуманной промежуточной классификации.
///
/// Значения упорядочены по возрастанию строгости, и на этом порядке держится весь отбор кандидатов
/// («до 23:00 не строже <see cref="Pg13"/>» — это обычное сравнение). Новые значения можно вставлять
/// только с сохранением монотонности и с миграцией данных.
///
/// Это не гейт для зрителя: что показывает канал, то и смотрится. Категория нужна планировщику,
/// Отсутствие рейтинга — это <c>null</c>, а не значение шкалы: «не проставлено» и «подходит всем» —
/// разные факты, и загонять их в одну ячейку значит терять оба.
///
/// Это не гейт для зрителя: что показывает канал, то и смотрится. Рейтинг нужен планировщику,
/// чтобы не поставить взрослое в детский эфир.
///
/// Имена на проводе заданы явно (<c>PG-13</c>, а не <c>Pg13</c>): по ним же приходят данные
/// источников, и совпадение написания избавляет от таблицы перевода на каждой границе.
/// </summary>
public enum ShowAudience
{
/// <summary>Детское.</summary>
Kids = 0,
/// <summary>General Audiences — без ограничений.</summary>
[JsonStringEnumMemberName("G")]
G = 0,
/// <summary>Семейное.</summary>
Family = 1,
/// <summary>Parental Guidance Suggested — рекомендуется присутствие родителей.</summary>
[JsonStringEnumMemberName("PG")]
Pg = 1,
/// <summary>Подростковое.</summary>
Teen = 2,
/// <summary>Parents Strongly Cautioned — не рекомендуется детям до 13 лет.</summary>
[JsonStringEnumMemberName("PG-13")]
Pg13 = 2,
/// <summary>Общее — без ограничений (по умолчанию).</summary>
General = 3,
/// <summary>Restricted — до 17 лет в сопровождении взрослого.</summary>
[JsonStringEnumMemberName("R")]
R = 3,
/// <summary>Взрослое.</summary>
Adult = 4,
/// <summary>No One 17 and Under Admitted — только для взрослых.</summary>
[JsonStringEnumMemberName("NC-17")]
Nc17 = 4,
}
@@ -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";
/// <summary>
/// Страны сертификации в порядке приоритета: берём первую, для которой у шоу есть рейтинг.
/// US впереди намеренно — его шкала совпадает с нашей и переводится точно, тогда как RU
/// (0+…18+) приводится приблизительно (см. ContentRating) и нужен ради покрытия там,
/// где американской сертификации у контента нет и не было.
/// </summary>
public IReadOnlyList<string> CertificationCountries { get; init; } = ["US", "RU"];
}
public sealed class OmdbOptions
@@ -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"))
);
}
/// <summary>Тип из выдачи OMDb: «series»/«movie»; «episode» и незнакомое — null (в шоу не годятся).</summary>
private static ShowKind? KindFrom(string? type) =>
type?.ToLowerInvariant() switch
{
"series" => ShowKind.Series,
"movie" => ShowKind.Single,
_ => null,
};
/// <summary>OMDb отдаёт жанры одной строкой через запятую: «Action, Sci-Fi».</summary>
private static List<string> GenresFrom(string? value) =>
string.IsNullOrWhiteSpace(value)
@@ -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)
);
}
/// <summary>
/// Возрастная сертификация из <c>content_ratings</c>: перебираем страны в порядке приоритета
/// (<see cref="TmdbOptions.CertificationCountries"/>) и берём первую непустую. Отдаём написание
/// источника как есть — приводит его к шкале приложение (ContentRating).
///
/// Пустая строка в <c>rating</c> — обычное дело: страна в списке есть, а рейтинга у неё нет,
/// и такую запись надо пропускать, а не считать ответом.
/// </summary>
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;
}
/// <summary>
/// Жанры из ответа TMDb в порядке источника: на каждый — сначала идентификатор
/// (<c>tmdb:28</c>), затем локализованное название. Идентификатор точнее, название — запасной
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ShowAudienceMpaa : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "Audience",
table: "Shows",
type: "integer",
nullable: true,
oldClrType: typeof(int),
oldType: "integer");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "Audience",
table: "Shows",
type: "integer",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
}
}
}
@@ -575,7 +575,7 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("Audience")
b.Property<int?>("Audience")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
@@ -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));
}
}
@@ -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()
{
@@ -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()
);