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
+5
View File
@@ -92,6 +92,11 @@ Media__LoudnessTargetLufs=-16
Metadata__Language=ru-RU Metadata__Language=ru-RU
Metadata__Tmdb__ApiKey= Metadata__Tmdb__ApiKey=
Metadata__Omdb__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 ────────────────────────────────────────────────────────── # ── ASP.NET Core ──────────────────────────────────────────────────────────
ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_ENVIRONMENT=Production
@@ -177,6 +177,7 @@ public sealed record RenameShowBody(string Name);
public sealed record SetShowOriginalNameBody(string? OriginalName); 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); public sealed record SetShowGenresBody(IReadOnlyList<Guid> GenreIds, Guid? PrimaryGenreId);
@@ -20,7 +20,7 @@ public sealed record CollectionItemDto(
int Position, int Position,
string ShowName, string ShowName,
ShowKind ShowKind, ShowKind ShowKind,
ShowAudience ShowAudience, ShowAudience? ShowAudience,
int EpisodeCount, int EpisodeCount,
int? Year, int? Year,
Guid? PosterImageId Guid? PosterImageId
@@ -46,7 +46,7 @@ public sealed class GetCollectionQueryHandler(IAppDbContext dbContext)
i.Position, i.Position,
show?.Name ?? "—", show?.Name ?? "—",
show?.Kind ?? default, show?.Kind ?? default,
show?.Audience ?? default, show?.Audience,
show?.EpisodeCount ?? 0, show?.EpisodeCount ?? 0,
show?.Year, show?.Year,
show?.PosterImageId show?.PosterImageId
@@ -9,5 +9,5 @@ public sealed record CreateShowCommand(
ShowKind Kind, ShowKind Kind,
string? Description, string? Description,
string? OriginalName = null, string? OriginalName = null,
ShowAudience Audience = ShowAudience.General ShowAudience? Audience = null
) : ICommand<Result<Guid>>; ) : ICommand<Result<Guid>>;
@@ -4,5 +4,5 @@ using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.SetShowAudience; namespace TeleWave.Application.Library.SetShowAudience;
/// <summary>Задать категорию аудитории шоу (обычное/детское/взрослое).</summary> /// <summary>Задать возрастной рейтинг шоу (MPAA); null — снять проставленный.</summary>
public sealed record SetShowAudienceCommand(Guid Id, ShowAudience Audience) : ICommand<Result>; public sealed record SetShowAudienceCommand(Guid Id, ShowAudience? Audience) : ICommand<Result>;
@@ -9,7 +9,7 @@ public sealed record ShowSummaryDto(
string Name, string Name,
string? OriginalName, string? OriginalName,
ShowKind Kind, ShowKind Kind,
ShowAudience Audience, ShowAudience? Audience,
int EpisodeCount, int EpisodeCount,
int SeasonCount, int SeasonCount,
int? Year, int? Year,
@@ -42,7 +42,7 @@ public sealed record ShowDto(
string Name, string Name,
string? OriginalName, string? OriginalName,
ShowKind Kind, ShowKind Kind,
ShowAudience Audience, ShowAudience? Audience,
string? Description, string? Description,
string? MetadataProvider, string? MetadataProvider,
string? MetadataExternalId, string? MetadataExternalId,
@@ -68,6 +68,12 @@ public sealed class ApplyShowMetadataCommandHandler(
if (genreIds.Count > 0) if (genreIds.Count > 0)
show.SetGenres(genreIds); show.SetGenres(genreIds);
// Рейтинг — по тому же правилу: проставляем только распознанный. «Not Rated», «N/A» и местные
// шкалы означают отсутствие данных, и снимать ими уже проставленный рейтинг нельзя — иначе
// «обновить метаданные» тихо открыло бы шоу дорогу в детское время.
if (ContentRating.Parse(meta.ContentRating) is { } audience)
show.SetAudience(audience);
return Result.Success(); 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; namespace TeleWave.Application.Metadata;
/// <summary>Кандидат из поиска по названию в источнике метаданных.</summary> /// <summary>
/// Кандидат из поиска по названию в источнике метаданных. <paramref name="Kind"/> — что нашлось,
/// сериал или полнометражка (null — источник ищет только по одному типу и различать нечего);
/// в выдаче, где вперемешку и то и другое, без этого «Фарго» 1996 и «Фарго» 2014 не различить.
/// </summary>
public sealed record MetadataCandidate( public sealed record MetadataCandidate(
string ExternalId, string ExternalId,
string Title, string Title,
int? Year, int? Year,
string? Overview, string? Overview,
string? PosterUrl string? PosterUrl,
ShowKind? Kind = null
); );
/// <summary> /// <summary>
@@ -14,6 +21,9 @@ public sealed record MetadataCandidate(
/// <paramref name="Genres"/> — сырые обозначения жанров в том порядке, в каком их отдал источник: /// <paramref name="Genres"/> — сырые обозначения жанров в том порядке, в каком их отдал источник:
/// идентификаторы (<c>tmdb:28</c>) и названия вперемешку. Сопоставление со справочником — /// идентификаторы (<c>tmdb:28</c>) и названия вперемешку. Сопоставление со справочником —
/// на стороне приложения (см. GenreMatcher); первый распознанный становится основным жанром шоу. /// на стороне приложения (см. GenreMatcher); первый распознанный становится основным жанром шоу.
/// <paramref name="ContentRating"/> — возрастной рейтинг ровно в том написании, в каком его отдал
/// источник (<c>PG-13</c>, <c>TV-14</c>, <c>Not Rated</c>); приведение к шкале — тоже на стороне
/// приложения (см. ContentRating), чтобы таблица соответствий жила в одном месте на все источники.
/// </summary> /// </summary>
public sealed record ShowMetadata( public sealed record ShowMetadata(
string ExternalId, string ExternalId,
@@ -21,7 +31,8 @@ public sealed record ShowMetadata(
int? Year, int? Year,
string? Overview, string? Overview,
string? PosterUrl, string? PosterUrl,
IReadOnlyList<string>? Genres = null IReadOnlyList<string>? Genres = null,
string? ContentRating = null
); );
/// <summary>Метаданные серии (для этапа 2).</summary> /// <summary>Метаданные серии (для этапа 2).</summary>
@@ -100,9 +100,12 @@ public sealed class FindGroupCandidatesQueryHandler(
if (filter.ShowKinds is { Count: > 0 } kinds) if (filter.ShowKinds is { Count: > 0 } kinds)
shows = shows.Where(s => kinds.Contains(s.Kind)); shows = shows.Where(s => kinds.Contains(s.Kind));
// Категории упорядочены по возрастанию строгости, поэтому «не строже» — обычное сравнение. // Рейтинги упорядочены по возрастанию строгости, поэтому «не строже» — обычное сравнение.
// Шоу без рейтинга остаются в выборке — ровно как в планировщике (ElementSelector): источники
// проставляют рейтинг далеко не всему, и молчаливое выбрасывание непроставленного оставило бы
// от фильтра «мультики не строже PG» горстку голливудского.
if (filter.MaxAudience is { } maxAudience) 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) if (filter.YearMin is { } yearMin)
shows = shows.Where(s => s.Year != null && s.Year >= yearMin); shows = shows.Where(s => s.Year != null && s.Year >= yearMin);
@@ -125,7 +125,7 @@ public sealed class GroupElementResolver(IAppDbContext dbContext)
partAssets.Count, partAssets.Count,
DurationOf(partAssets), DurationOf(partAssets),
null, null,
// Категория коллекции — самая строгая среди частей: по ней отбирают в детское время. // Рейтинг коллекции — самый строгий среди частей: по нему отбирают в детское время.
parts.Count == 0 ? null : parts.Max(p => p!.Audience), parts.Count == 0 ? null : parts.Max(p => p!.Audience),
parts.Count == 0 ? null : parts.Min(p => p!.Year), parts.Count == 0 ? null : parts.Min(p => p!.Year),
collection.PosterImageId collection.PosterImageId
@@ -150,7 +150,7 @@ public sealed class GroupExpander(IAppDbContext dbContext)
.Where(p => p is not null) .Where(p => p is not null)
.DefaultIfEmpty(null) .DefaultIfEmpty(null)
.Max(), .Max(),
// Категория коллекции — строжайшая среди частей: франшиза идёт целиком, // Рейтинг коллекции — строжайший среди частей: франшиза идёт целиком,
// и одна взрослая часть делает взрослой всю. // и одна взрослая часть делает взрослой всю.
partShowIds partShowIds
.Select(id => .Select(id =>
@@ -245,8 +245,9 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
} }
/// <summary> /// <summary>
/// Строжайшая категория среди позиций каждой группы: именно она конфликтует с детским временем. /// Строжайший рейтинг среди позиций каждой группы: именно он конфликтует с детским временем.
/// Коллекция берётся по строжайшей части — франшиза идёт целиком. /// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не
/// идут: планировщик их не отсекает, значит и предупреждать не о чем.
/// </summary> /// </summary>
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync( private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
IReadOnlyCollection<Guid> groupIds, IReadOnlyCollection<Guid> groupIds,
@@ -305,7 +306,7 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
foreach (var showId in candidates) foreach (var showId in candidates)
{ {
if (!audiences.TryGetValue(showId, out var audience)) if (audiences.GetValueOrDefault(showId) is not { } audience)
continue; continue;
if (!result.TryGetValue(item.GroupId, out var current) || audience > current) if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
result[item.GroupId] = audience; result[item.GroupId] = audience;
+6 -5
View File
@@ -21,8 +21,9 @@ public class Show
public string? Description { get; private set; } public string? Description { get; private set; }
public ShowKind Kind { get; private set; } public ShowKind Kind { get; private set; }
/// <summary>Категория аудитории (обычное/детское/взрослое) — под будущие фильтры показа.</summary> /// <summary>Возрастной рейтинг (MPAA) или null, если не проставлен ни источником, ни вручную.
public ShowAudience Audience { get; private set; } /// Шоу без рейтинга планировщик не отсекает: неизвестное не значит «взрослое».</summary>
public ShowAudience? Audience { get; private set; }
public DateTimeOffset CreatedAt { get; private set; } public DateTimeOffset CreatedAt { get; private set; }
@@ -52,7 +53,7 @@ public class Show
ShowKind kind, ShowKind kind,
string? description = null, string? description = null,
string? originalName = null, string? originalName = null,
ShowAudience audience = ShowAudience.General ShowAudience? audience = null
) => ) =>
new() new()
{ {
@@ -65,8 +66,8 @@ public class Show
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}; };
/// <summary>Задать категорию аудитории.</summary> /// <summary>Задать возрастной рейтинг; null — снять (вернуть в «не проставлен»).</summary>
public void SetAudience(ShowAudience audience) => Audience = audience; public void SetAudience(ShowAudience? audience) => Audience = audience;
/// <summary>Основной жанр или null, если жанры не проставлены.</summary> /// <summary>Основной жанр или null, если жанры не проставлены.</summary>
public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId; public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId;
@@ -1,27 +1,43 @@
using System.Text.Json.Serialization;
namespace TeleWave.Domain.Library; namespace TeleWave.Domain.Library;
/// <summary> /// <summary>
/// Возрастная категория шоу. Значения упорядочены по возрастанию строгости — на этом порядке строятся /// Возрастной рейтинг шоу по шкале MPAA — той самой, что отдают внешние источники, поэтому значение
/// правила планировщика вида «до 23:00 не строже подросткового». Новые категории можно вставлять /// из метаданных ложится сюда без выдуманной промежуточной классификации.
///
/// Значения упорядочены по возрастанию строгости, и на этом порядке держится весь отбор кандидатов
/// («до 23:00 не строже <see cref="Pg13"/>» — это обычное сравнение). Новые значения можно вставлять
/// только с сохранением монотонности и с миграцией данных. /// только с сохранением монотонности и с миграцией данных.
/// ///
/// Это не гейт для зрителя: что показывает канал, то и смотрится. Категория нужна планировщику, /// Отсутствие рейтинга — это <c>null</c>, а не значение шкалы: «не проставлено» и «подходит всем» —
/// разные факты, и загонять их в одну ячейку значит терять оба.
///
/// Это не гейт для зрителя: что показывает канал, то и смотрится. Рейтинг нужен планировщику,
/// чтобы не поставить взрослое в детский эфир. /// чтобы не поставить взрослое в детский эфир.
///
/// Имена на проводе заданы явно (<c>PG-13</c>, а не <c>Pg13</c>): по ним же приходят данные
/// источников, и совпадение написания избавляет от таблицы перевода на каждой границе.
/// </summary> /// </summary>
public enum ShowAudience public enum ShowAudience
{ {
/// <summary>Детское.</summary> /// <summary>General Audiences — без ограничений.</summary>
Kids = 0, [JsonStringEnumMemberName("G")]
G = 0,
/// <summary>Семейное.</summary> /// <summary>Parental Guidance Suggested — рекомендуется присутствие родителей.</summary>
Family = 1, [JsonStringEnumMemberName("PG")]
Pg = 1,
/// <summary>Подростковое.</summary> /// <summary>Parents Strongly Cautioned — не рекомендуется детям до 13 лет.</summary>
Teen = 2, [JsonStringEnumMemberName("PG-13")]
Pg13 = 2,
/// <summary>Общее — без ограничений (по умолчанию).</summary> /// <summary>Restricted — до 17 лет в сопровождении взрослого.</summary>
General = 3, [JsonStringEnumMemberName("R")]
R = 3,
/// <summary>Взрослое.</summary> /// <summary>No One 17 and Under Admitted — только для взрослых.</summary>
Adult = 4, [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 ImageBaseUrl { get; init; } = "https://image.tmdb.org/t/p";
public string PosterSize { get; init; } = "w500"; public string PosterSize { get; init; } = "w500";
public string StillSize { get; init; } = "w300"; 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 public sealed class OmdbOptions
@@ -3,6 +3,7 @@ using System.Text.Json;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Metadata; using TeleWave.Application.Metadata;
using TeleWave.Domain.Library;
using static TeleWave.Infrastructure.Metadata.MetadataJson; using static TeleWave.Infrastructure.Metadata.MetadataJson;
namespace TeleWave.Infrastructure.Metadata; namespace TeleWave.Infrastructure.Metadata;
@@ -22,8 +23,10 @@ public sealed class OmdbMetadataProvider(
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var url = // Тип в запросе не фиксируем: imdbID уникален и для фильма, и для сериала, а GetShowAsync
$"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&type=series&s={Uri.EscapeDataString(query)}"; // ходит по нему же — значит полнометражки находятся и обогащаются тем же путём, без
// разделения пространств идентификаторов (в отличие от TMDb, где tv и movie нумеруются врозь).
var url = $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&s={Uri.EscapeDataString(query)}";
using var doc = await GetAsync(httpFactory, url, cancellationToken); using var doc = await GetAsync(httpFactory, url, cancellationToken);
if (!doc.RootElement.TryGetProperty("Search", out var search)) if (!doc.RootElement.TryGetProperty("Search", out var search))
return []; return [];
@@ -34,13 +37,20 @@ public sealed class OmdbMetadataProvider(
var id = Clean(GetString(item, "imdbID")); var id = Clean(GetString(item, "imdbID"));
if (id is null) if (id is null)
continue; continue;
// Отдельные серии в выдаче — шум: шоу из них не собирают, а список они забивают.
var kind = KindFrom(Clean(GetString(item, "Type")));
if (kind is null)
continue;
list.Add( list.Add(
new MetadataCandidate( new MetadataCandidate(
id, id,
Clean(GetString(item, "Title")) ?? "—", Clean(GetString(item, "Title")) ?? "—",
YearFrom(GetString(item, "Year")), YearFrom(GetString(item, "Year")),
null, null,
Clean(GetString(item, "Poster")) Clean(GetString(item, "Poster")),
kind
) )
); );
} }
@@ -63,10 +73,22 @@ public sealed class OmdbMetadataProvider(
YearFrom(GetString(root, "Year")), YearFrom(GetString(root, "Year")),
Clean(GetString(root, "Plot")), Clean(GetString(root, "Plot")),
Clean(GetString(root, "Poster")), 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> /// <summary>OMDb отдаёт жанры одной строкой через запятую: «Action, Sci-Fi».</summary>
private static List<string> GenresFrom(string? value) => private static List<string> GenresFrom(string? value) =>
string.IsNullOrWhiteSpace(value) string.IsNullOrWhiteSpace(value)
@@ -3,6 +3,7 @@ using System.Text.Json;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Metadata; using TeleWave.Application.Metadata;
using TeleWave.Domain.Library;
using static TeleWave.Infrastructure.Metadata.MetadataJson; using static TeleWave.Infrastructure.Metadata.MetadataJson;
namespace TeleWave.Infrastructure.Metadata; namespace TeleWave.Infrastructure.Metadata;
@@ -43,7 +44,10 @@ public sealed class TmdbMetadataProvider(
GetString(item, "name") ?? "—", GetString(item, "name") ?? "—",
YearFrom(GetString(item, "first_air_date")), YearFrom(GetString(item, "first_air_date")),
GetString(item, "overview"), 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 CancellationToken cancellationToken
) )
{ {
// content_ratings подвешиваем к тому же запросу — отдельного обращения к API не требуется.
var url = 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); using var doc = await TryGetAsync(httpFactory, url, cancellationToken);
if (doc is null) if (doc is null)
return null; return null;
@@ -67,10 +73,49 @@ public sealed class TmdbMetadataProvider(
YearFrom(GetString(root, "first_air_date")), YearFrom(GetString(root, "first_air_date")),
GetString(root, "overview"), GetString(root, "overview"),
PosterUrl(GetString(root, "poster_path")), 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> /// <summary>
/// Жанры из ответа TMDb в порядке источника: на каждый — сначала идентификатор /// Жанры из ответа TMDb в порядке источника: на каждый — сначала идентификатор
/// (<c>tmdb:28</c>), затем локализованное название. Идентификатор точнее, название — запасной /// (<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") b.Property<Guid>("Id")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<int>("Audience") b.Property<int?>("Audience")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt") 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) public void AudienceAt_DayWindow(int hour, int minute, bool inside)
{ {
var rules = new PlanningRules( 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)); var result = rules.AudienceAt(new TimeOnly(hour, minute));
Assert.Equal(inside ? ShowAudience.Teen : null, result); Assert.Equal(inside ? ShowAudience.Pg13 : null, result);
} }
[Theory] [Theory]
@@ -32,12 +32,12 @@ public class PlanningRulesTests
{ {
// «С 23:00 до 06:00» — ночное окно, границы сравниваются в обратную сторону. // «С 23:00 до 06:00» — ночное окно, границы сравниваются в обратную сторону.
var rules = new PlanningRules( 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)); var result = rules.AudienceAt(new TimeOnly(hour, minute));
Assert.Equal(inside ? ShowAudience.Adult : null, result); Assert.Equal(inside ? ShowAudience.Nc17 : null, result);
} }
[Fact] [Fact]
@@ -46,13 +46,13 @@ public class PlanningRulesTests
// Широкое окно, случайно наложенное поверх детского, не должно его отменять. // Широкое окно, случайно наложенное поверх детского, не должно его отменять.
var rules = new PlanningRules( var rules = new PlanningRules(
[ [
new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(23, 0), ShowAudience.General), new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(23, 0), ShowAudience.R),
new AudienceWindow(new TimeOnly(7, 0), new TimeOnly(10, 0), ShowAudience.Kids), 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.G, rules.AudienceAt(new TimeOnly(8, 0)));
Assert.Equal(ShowAudience.General, rules.AudienceAt(new TimeOnly(12, 0))); Assert.Equal(ShowAudience.R, rules.AudienceAt(new TimeOnly(12, 0)));
} }
[Fact] [Fact]
@@ -65,7 +65,7 @@ public class PlanningRulesTests
public void Json_RoundTrips() public void Json_RoundTrips()
{ {
var rules = new PlanningRules( 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), new RepeatLimitRule(7, 2),
MaxBreakMinutesPerHour: 12, MaxBreakMinutesPerHour: 12,
MaxGenreSharePercent: 40, MaxGenreSharePercent: 40,
@@ -84,6 +84,19 @@ public class PlanningRulesTests
Assert.Equal(rules.MaxFallbackSharePercent, restored.MaxFallbackSharePercent); 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] [Fact]
public void FromJson_Garbage_IsNull() public void FromJson_Garbage_IsNull()
{ {
@@ -66,23 +66,23 @@ public class CandidateFilterTests
[Fact] [Fact]
public void Audience_FiltersOutStricterContent() public void Audience_FiltersOutStricterContent()
{ {
var kids = Element(ShowAudience.Kids); var forEveryone = Element(ShowAudience.G);
var adult = Element(ShowAudience.Adult, position: 1); var adult = Element(ShowAudience.Nc17, position: 1);
var pick = ElementSelector.Select( var pick = ElementSelector.Select(
Slot([adult, kids], maxAudience: ShowAudience.Family), Slot([adult, forEveryone], maxAudience: ShowAudience.Pg),
T0, T0,
new FirstAlways() new FirstAlways()
); );
Assert.NotNull(pick); Assert.NotNull(pick);
Assert.Equal(kids.ElementId, pick.Element.ElementId); Assert.Equal(forEveryone.ElementId, pick.Element.ElementId);
} }
[Fact] [Fact]
public void Audience_WithoutLimit_KeepsEverything() public void Audience_WithoutLimit_KeepsEverything()
{ {
var adult = Element(ShowAudience.Adult); var adult = Element(ShowAudience.Nc17);
var pick = ElementSelector.Select(Slot([adult]), T0, new FirstAlways()); var pick = ElementSelector.Select(Slot([adult]), T0, new FirstAlways());
@@ -91,13 +91,13 @@ public class CandidateFilterTests
} }
[Fact] [Fact]
public void Audience_UnknownCategory_IsNotDropped() public void Audience_UnknownRating_IsNotDropped()
{ {
// Неизвестная категория — не повод выбрасывать: иначе контент исчезал бы из эфира молча. // Непроставленный рейтинг — не повод выбрасывать: иначе контент исчезал бы из эфира молча.
var unknown = Element(audience: null); var unknown = Element(audience: null);
var pick = ElementSelector.Select( var pick = ElementSelector.Select(
Slot([unknown], maxAudience: ShowAudience.Kids), Slot([unknown], maxAudience: ShowAudience.G),
T0, T0,
new FirstAlways() new FirstAlways()
); );
@@ -110,7 +110,7 @@ public class CandidateFilterTests
public void Audience_AllFilteredOut_ReturnsNothing() public void Audience_AllFilteredOut_ReturnsNothing()
{ {
var pick = ElementSelector.Select( var pick = ElementSelector.Select(
Slot([Element(ShowAudience.Adult)], maxAudience: ShowAudience.Kids), Slot([Element(ShowAudience.Nc17)], maxAudience: ShowAudience.G),
T0, T0,
new FirstAlways() new FirstAlways()
); );
@@ -121,11 +121,11 @@ public class CandidateFilterTests
[Fact] [Fact]
public void Audience_AppliesToSequentialStrategyToo() public void Audience_AppliesToSequentialStrategyToo()
{ {
var adult = Element(ShowAudience.Adult, position: 0); var adult = Element(ShowAudience.Nc17, position: 0);
var teen = Element(ShowAudience.Teen, position: 1); var teen = Element(ShowAudience.Pg13, position: 1);
var pick = ElementSelector.Select( var pick = ElementSelector.Select(
Slot([adult, teen], maxAudience: ShowAudience.Teen, strategy: SlotStrategyKind.Sequential), Slot([adult, teen], maxAudience: ShowAudience.Pg13, strategy: SlotStrategyKind.Sequential),
T0, T0,
new FirstAlways() new FirstAlways()
); );
+19 -13
View File
@@ -28,7 +28,7 @@
канал создаётся с нуля, но так, чтобы это было приятно. канал создаётся с нуля, но так, чтобы это было приятно.
Возрастных ограничений на стороне зрителя нет. Что показывает канал — то и смотришь, как и было. Возрастных ограничений на стороне зрителя нет. Что показывает канал — то и смотришь, как и было.
Возрастная категория — инструмент планирования (не поставить взрослое в детский эфир), а не гейт. Возрастной рейтинг — инструмент планирования (не поставить взрослое в детский эфир), а не гейт.
### 1.1. От чего уходим ### 1.1. От чего уходим
@@ -128,19 +128,25 @@ ShowGenre — многие-ко-многим
Отдельных свободных тегов не заводим: их роль («ретро», «новогоднее») выполняют группы с ручным Отдельных свободных тегов не заводим: их роль («ретро», «новогоднее») выполняют группы с ручным
составом, а это одна сущность вместо двух. составом, а это одна сущность вместо двух.
**Возрастная категория.** `ShowAudience` расширяется до пяти значений, **упорядоченных по возрастанию **Возрастной рейтинг.** `ShowAudience` — шкала MPAA, **упорядоченная по возрастанию строгости**:
строгости** — правила формулируются как «до 23:00 не строже подросткового», поэтому порядок в enum правила формулируются как «до 23:00 не строже PG-13», поэтому порядок в enum значим.
значим:
``` ```
Kidsдетское G без ограничений
Family — семейное PG — с родителями
Teen — подростковое PG-13 — не рекомендуется до 13
General — общее R — до 17 со взрослым
Adult — взрослое NC-17 — только взрослым
``` ```
Текущие три значения (`General=0, Kids=1, Adult=2`) не отсортированы и переписываются. Шкала взята не абстрактная, а ровно та, которой оперируют внешние источники (`Rated` у OMDb,
сертификации у TMDb): рейтинг из метаданных ложится в поле без промежуточной классификации,
которую пришлось бы придумывать и объяснять.
**Отсутствие рейтинга — `null`, а не значение шкалы.** «Не проставлен» и «подходит всем» — разные
факты; в одной ячейке теряются оба. Контент без рейтинга планировщик **не отсекает**: источники
проставляют рейтинг далеко не всему (на неамериканском кино чаще всего не проставляют вовсе),
и трактовка «неизвестное значит взрослое» вымела бы из эфира большую часть библиотеки.
**Коллекция** — франшиза: упорядоченный набор шоу. «Терминатор» — это три `Show(Single)` в порядке **Коллекция** — франшиза: упорядоченный набор шоу. «Терминатор» — это три `Show(Single)` в порядке
1→2→3. Живёт в библиотеке рядом с шоу, потому что «одно произведение из трёх частей» — факт о контенте, 1→2→3. Живёт в библиотеке рядом с шоу, потому что «одно произведение из трёх частей» — факт о контенте,
@@ -209,7 +215,7 @@ GroupItem
"elementKinds": ["show", "collection"], "elementKinds": ["show", "collection"],
"showKinds": ["series", "single"], "showKinds": ["series", "single"],
"genres": { "anyOf": ["боевик", "триллер"] }, "genres": { "anyOf": ["боевик", "триллер"] },
"audience": { "max": "Teen" }, "audience": { "max": "PG-13" },
"year": { "min": 1989, "max": 1999 }, "year": { "min": 1989, "max": 1999 },
"unitDurationMinutes": { "min": 20, "max": 25 } "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 дней | | `cooldown` | `{days}` | Не показывать элемент, игравший последние N дней |
| `maxRepeatsInWindow` | `{windowDays, max}` | Не чаще N раз за период | | `maxRepeatsInWindow` | `{windowDays, max}` | Не чаще N раз за период |
@@ -654,7 +660,7 @@ seed = hash(channelId, date, slotId, occurrenceInDay)
| Дыра в сетке | интервал не покрыт даже фоном | «Не покрыто: вт 03:00–06:00» | | Дыра в сетке | интервал не покрыт даже фоном | «Не покрыто: вт 03:00–06:00» |
| Пересечение слотов | overlap внутри одного слоя | «Слоты пересекаются» | | Пересечение слотов | overlap внутри одного слоя | «Слоты пересекаются» |
| Недостижимый кулдаун | `cooldownDays × выходов в день > itemCount` | «Остывание 28 дней невыполнимо при 3 позициях» | | Недостижимый кулдаун | `cooldownDays × выходов в день > itemCount` | «Остывание 28 дней невыполнимо при 3 позициях» |
| Возрастной конфликт | группа содержит строже, чем позволяет окно | «В группе есть 18+, слот стоит в детском времени» | | Возрастной конфликт | группа содержит строже, чем позволяет окно | «В группе есть `NC-17`, слот стоит в детском времени» |
Запас группы показывается прямо в инспекторе слота: «342 позиции · 118 ч · полный цикл 6.2 недели». Запас группы показывается прямо в инспекторе слота: «342 позиции · 118 ч · полный цикл 6.2 недели».
@@ -15,7 +15,7 @@ import { Label } from '@/shared/ui/label'
import { updateTemplate } from '../api' import { updateTemplate } from '../api'
import { CollapsibleCard } from './CollapsibleCard' 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): детское время и потолок повторов. Это жёсткие * Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
@@ -4,7 +4,12 @@ import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ChevronLeft } from 'lucide-react' import { ChevronLeft } from 'lucide-react'
import { qk } from '@/shared/api/query-keys' 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 { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
@@ -51,7 +56,7 @@ export function ShowDetail({ showId }: { showId: string }) {
const onError = useApiError() const onError = useApiError()
const audienceMutation = useMutation({ const audienceMutation = useMutation({
mutationFn: (audience: ShowAudience) => setShowAudience(showId, audience), mutationFn: (audience: ShowAudience | null) => setShowAudience(showId, audience),
onSuccess: invalidate, onSuccess: invalidate,
onError, onError,
}) })
@@ -150,13 +155,16 @@ export function ShowDetail({ showId }: { showId: string }) {
<h2 className="crt-glow text-xl font-semibold">{show.name}</h2> <h2 className="crt-glow text-xl font-semibold">{show.name}</h2>
<Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge> <Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge>
<Select <Select
value={show.audience} value={show.audience ?? AUDIENCE_UNSET}
onValueChange={(v) => audienceMutation.mutate(v as ShowAudience)} onValueChange={(v) =>
audienceMutation.mutate(v === AUDIENCE_UNSET ? null : (v as ShowAudience))
}
> >
<SelectTrigger className="h-7 w-32"> <SelectTrigger className="h-7 w-48">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={AUDIENCE_UNSET}>{t('admin.shows.audienceUnset')}</SelectItem>
{SHOW_AUDIENCES.map((value) => ( {SHOW_AUDIENCES.map((value) => (
<SelectItem key={value} value={value}> <SelectItem key={value} value={value}>
{t(`admin.shows.audiences.${value}`)} {t(`admin.shows.audiences.${value}`)}
@@ -5,6 +5,7 @@ import { Loader2 } from 'lucide-react'
import { qk } from '@/shared/api/query-keys' import { qk } from '@/shared/api/query-keys'
import type { MetadataCandidate, MissingEpisodesReport, ShowDto } from '@/shared/api/types' import type { MetadataCandidate, MissingEpisodesReport, ShowDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error' import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
@@ -199,10 +200,17 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
<div className="h-16 w-11 shrink-0 rounded bg-muted/40" /> <div className="h-16 w-11 shrink-0 rounded bg-muted/40" />
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium"> <div className="flex flex-wrap items-center gap-2 text-sm font-medium">
{r.title} <span>
{r.year != null && ( {r.title}
<span className="text-muted-foreground"> ({r.year})</span> {r.year != null && (
<span className="text-muted-foreground"> ({r.year})</span>
)}
</span>
{/* В выдаче OMDb сериалы и полнометражки идут вперемешку — без метки
одноимённые фильм и сериал не различить. */}
{r.kind && (
<Badge variant="muted">{t(`admin.shows.kinds.${r.kind}`)}</Badge>
)} )}
</div> </div>
{r.overview && ( {r.overview && (
@@ -3,7 +3,12 @@ import { Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys' import { qk } from '@/shared/api/query-keys'
import { SHOW_AUDIENCES, type ShowAudience, type ShowKind } from '@/shared/api/types' import {
AUDIENCE_UNSET,
SHOW_AUDIENCES,
type ShowAudience,
type ShowKind,
} from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error' import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
@@ -22,7 +27,8 @@ export function ShowsPanel() {
const [name, setName] = useState('') const [name, setName] = useState('')
const [originalName, setOriginalName] = useState('') const [originalName, setOriginalName] = useState('')
const [kind, setKind] = useState<ShowKind>('Series') const [kind, setKind] = useState<ShowKind>('Series')
const [audience, setAudience] = useState<ShowAudience>('General') // Новое шоу заводится без рейтинга: проставят метаданные либо админ руками.
const [audience, setAudience] = useState<ShowAudience | null>(null)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const { sort, toggle } = useTableSort('name', false) const { sort, toggle } = useTableSort('name', false)
@@ -52,7 +58,8 @@ export function ShowsPanel() {
return sortRows(matched, sort, { return sortRows(matched, sort, {
name: (s) => s.name.toLowerCase(), name: (s) => s.name.toLowerCase(),
kind: (s) => s.kind, kind: (s) => s.kind,
audience: (s) => s.audience, // Без рейтинга — в начало: пустое значение сортируется раньше любого кода.
audience: (s) => s.audience ?? '',
genre: (s) => (s.primaryGenre ?? '').toLowerCase(), genre: (s) => (s.primaryGenre ?? '').toLowerCase(),
seasons: (s) => s.seasonCount, seasons: (s) => s.seasonCount,
episodes: (s) => s.episodeCount, episodes: (s) => s.episodeCount,
@@ -106,11 +113,15 @@ export function ShowsPanel() {
<SelectItem value="Single">{t('admin.shows.kinds.Single')}</SelectItem> <SelectItem value="Single">{t('admin.shows.kinds.Single')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={audience} onValueChange={(v) => setAudience(v as ShowAudience)}> <Select
<SelectTrigger className="w-40"> value={audience ?? AUDIENCE_UNSET}
onValueChange={(v) => setAudience(v === AUDIENCE_UNSET ? null : (v as ShowAudience))}
>
<SelectTrigger className="w-48">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={AUDIENCE_UNSET}>{t('admin.shows.audienceUnset')}</SelectItem>
{SHOW_AUDIENCES.map((value) => ( {SHOW_AUDIENCES.map((value) => (
<SelectItem key={value} value={value}> <SelectItem key={value} value={value}>
{t(`admin.shows.audiences.${value}`)} {t(`admin.shows.audiences.${value}`)}
@@ -224,7 +235,13 @@ export function ShowsPanel() {
<Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge> <Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge>
</td> </td>
<td className="px-4 py-2"> <td className="px-4 py-2">
<Badge variant="muted">{t(`admin.shows.audiences.${show.audience}`)}</Badge> {/* В таблице показываем сам код MPAA: он короткий и одинаков во всех локалях,
а расшифровка есть в селекте на карточке шоу. */}
{show.audience ? (
<Badge variant="muted">{show.audience}</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</td> </td>
<td className="px-4 py-2 text-muted-foreground">{show.primaryGenre ?? '—'}</td> <td className="px-4 py-2 text-muted-foreground">{show.primaryGenre ?? '—'}</td>
<td className="px-4 py-2 text-muted-foreground">{show.seasonCount}</td> <td className="px-4 py-2 text-muted-foreground">{show.seasonCount}</td>
+3 -2
View File
@@ -23,12 +23,13 @@ export function createShow(body: {
kind: ShowKind kind: ShowKind
description?: string description?: string
originalName?: string originalName?: string
audience?: ShowAudience audience?: ShowAudience | null
}) { }) {
return apiRequest<CreatedIdResponse>('/admin/shows', { method: 'POST', body }) return apiRequest<CreatedIdResponse>('/admin/shows', { method: 'POST', body })
} }
export function setShowAudience(id: string, audience: ShowAudience) { /** `null` — снять проставленный рейтинг. */
export function setShowAudience(id: string, audience: ShowAudience | null) {
return apiRequest<void>(`/admin/shows/${id}/audience`, { method: 'PUT', body: { audience } }) return apiRequest<void>(`/admin/shows/${id}/audience`, { method: 'PUT', body: { audience } })
} }
+17 -6
View File
@@ -128,18 +128,27 @@ export type GenreDto = {
/** `Interstitial` — ролик-врезка: то же шоу, но со своим экраном и вне общей библиотеки. */ /** `Interstitial` — ролик-врезка: то же шоу, но со своим экраном и вне общей библиотеки. */
export type ShowKind = 'Series' | 'Single' | '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 = { export type ShowSummaryDto = {
id: string id: string
name: string name: string
originalName: string | null originalName: string | null
kind: ShowKind kind: ShowKind
audience: ShowAudience audience: ShowAudience | null
episodeCount: number episodeCount: number
seasonCount: number seasonCount: number
year: number | null year: number | null
@@ -192,7 +201,7 @@ type CollectionItemDto = {
position: number position: number
showName: string showName: string
showKind: ShowKind showKind: ShowKind
showAudience: ShowAudience showAudience: ShowAudience | null
episodeCount: number episodeCount: number
year: number | null year: number | null
posterImageId: string | null posterImageId: string | null
@@ -286,6 +295,8 @@ export type MetadataCandidate = {
year: number | null year: number | null
overview: string | null overview: string | null
posterUrl: string | null posterUrl: string | null
/** Сериал или полнометражка; null — источник ищет только по одному типу. */
kind: ShowKind | null
} }
type SeasonGapDto = { type SeasonGapDto = {
@@ -327,7 +338,7 @@ export type ShowDto = {
name: string name: string
originalName: string | null originalName: string | null
kind: ShowKind kind: ShowKind
audience: ShowAudience audience: ShowAudience | null
description: string | null description: string | null
metadataProvider: string | null metadataProvider: string | null
metadataExternalId: string | null metadataExternalId: string | null
+9 -7
View File
@@ -108,7 +108,8 @@ export const en = {
genres: 'Genres', genres: 'Genres',
genresHint: 'Any of the checked ones.', genresHint: 'Any of the checked ones.',
maxAudience: 'No stricter than', 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', year: 'Year',
unitMinutes: 'Unit runtime, min', unitMinutes: 'Unit runtime, min',
unitMinutesHint: unitMinutesHint:
@@ -294,13 +295,14 @@ export const en = {
originalName: 'Original name (eng)', originalName: 'Original name (eng)',
kind: 'Kind', kind: 'Kind',
kinds: { Series: 'Series', Single: 'Movie', Interstitial: 'Clip' }, kinds: { Series: 'Series', Single: 'Movie', Interstitial: 'Clip' },
audience: 'Category', audience: 'Rating',
audienceUnset: 'Not rated',
audiences: { audiences: {
Kids: 'Kids', G: 'G — general audiences',
Family: 'Family', PG: 'PG — parental guidance',
Teen: 'Teen', 'PG-13': 'PG-13 — over 13',
General: 'General', R: 'R — under 17 with adult',
Adult: 'Adult', 'NC-17': 'NC-17 — adults only',
}, },
seasons: 'Seasons', seasons: 'Seasons',
loadedSeasons: 'Loaded seasons', loadedSeasons: 'Loaded seasons',
+9 -7
View File
@@ -108,7 +108,8 @@ export const ru = {
genres: 'Жанры', genres: 'Жанры',
genresHint: 'Любой из отмеченных.', genresHint: 'Любой из отмеченных.',
maxAudience: 'Возраст не строже', maxAudience: 'Возраст не строже',
audienceHint: 'Категории упорядочены по строгости: детское → семейное → … → взрослое.', audienceHint:
'Рейтинги упорядочены по строгости: G → PG → PG-13 → R → NC-17. Шоу без рейтинга остаются в выборке.',
year: 'Год', year: 'Год',
unitMinutes: 'Длительность единицы, мин', unitMinutes: 'Длительность единицы, мин',
unitMinutesHint: unitMinutesHint:
@@ -294,13 +295,14 @@ export const ru = {
originalName: 'Оригинальное название (eng)', originalName: 'Оригинальное название (eng)',
kind: 'Тип', kind: 'Тип',
kinds: { Series: 'Сериал', Single: 'Полнометражка', Interstitial: 'Ролик' }, kinds: { Series: 'Сериал', Single: 'Полнометражка', Interstitial: 'Ролик' },
audience: 'Категория', audience: 'Рейтинг',
audienceUnset: 'Не проставлен',
audiences: { audiences: {
Kids: 'Детское', G: 'G — без ограничений',
Family: 'Семейное', PG: 'PG — с родителями',
Teen: 'Подростковое', 'PG-13': 'PG-13 — с 13 лет',
General: 'Общее', R: 'R — с 17 со взрослым',
Adult: 'Взрослое', 'NC-17': 'NC-17 — только взрослым',
}, },
seasons: 'Сезоны', seasons: 'Сезоны',
loadedSeasons: 'Загружены сезоны', loadedSeasons: 'Загружены сезоны',