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
@@ -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")