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
@@ -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;