Enhance metadata handling by introducing ShowKind parameter across various components. Update SearchShowMetadataQuery and related handlers to support querying by show type, ensuring accurate metadata retrieval for series and films. Implement validation to prevent episodes from being associated with non-series shows. Update frontend API to include ShowKind in metadata search requests, improving overall functionality and user experience.
ci / build-backend (push) Successful in 3m42s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 2m10s
ci / sonar (push) Successful in 6m40s

This commit is contained in:
Leonid Pershin
2026-07-26 22:29:16 +03:00
parent fdb0f321e4
commit b0740503bb
14 changed files with 327 additions and 40 deletions
@@ -20,13 +20,16 @@ public sealed class OmdbMetadataProvider(
public async Task<IReadOnlyList<MetadataCandidate>> SearchShowsAsync(
string query,
ShowKind kind,
CancellationToken cancellationToken
)
{
// Тип в запросе не фиксируем: imdbID уникален и для фильма, и для сериала, а GetShowAsync
// ходит по нему же — значит полнометражки находятся и обогащаются тем же путём, без
// разделения пространств идентификаторов (в отличие от TMDb, где tv и movie нумеруются врозь).
var url = $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&s={Uri.EscapeDataString(query)}";
// Тип сужает выдачу, но не влияет на идентификатор: imdbID уникален и для фильма, и для
// сериала, поэтому GetShowAsync ходит по нему одинаково — в отличие от TMDb, где tv и movie
// нумеруются врозь.
var type = kind == ShowKind.Series ? "series" : "movie";
var url =
$"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&type={type}&s={Uri.EscapeDataString(query)}";
using var doc = await GetAsync(httpFactory, url, cancellationToken);
if (!doc.RootElement.TryGetProperty("Search", out var search))
return [];
@@ -38,9 +41,10 @@ public sealed class OmdbMetadataProvider(
if (id is null)
continue;
// Отдельные серии в выдаче — шум: шоу из них не собирают, а список они забивают.
var kind = KindFrom(Clean(GetString(item, "Type")));
if (kind is null)
// Тип читаем из ответа, а не подставляем запрошенный: фильтр в запросе OMDb соблюдает,
// но отдельные серии (Type = episode) иногда просачиваются, а шоу из них не собирают.
var found = KindFrom(Clean(GetString(item, "Type")));
if (found is null)
continue;
list.Add(
@@ -50,15 +54,17 @@ public sealed class OmdbMetadataProvider(
YearFrom(GetString(item, "Year")),
null,
Clean(GetString(item, "Poster")),
kind
found
)
);
}
return list;
}
/// <summary>Тип здесь не используется: imdbID однозначен для фильмов и сериалов одинаково.</summary>
public async Task<ShowMetadata?> GetShowAsync(
string externalId,
ShowKind kind,
CancellationToken cancellationToken
)
{
@@ -22,11 +22,13 @@ public sealed class TmdbMetadataProvider(
public async Task<IReadOnlyList<MetadataCandidate>> SearchShowsAsync(
string query,
ShowKind kind,
CancellationToken cancellationToken
)
{
var movie = IsMovie(kind);
var url =
$"{Tmdb.BaseUrl}/search/tv?api_key={Tmdb.ApiKey}&language={_options.Language}"
$"{Tmdb.BaseUrl}/search/{Segment(movie)}?api_key={Tmdb.ApiKey}&language={_options.Language}"
+ $"&include_adult=false&query={Uri.EscapeDataString(query)}";
using var doc = await GetAsync(httpFactory, url, cancellationToken);
if (!doc.RootElement.TryGetProperty("results", out var results))
@@ -41,56 +43,117 @@ public sealed class TmdbMetadataProvider(
list.Add(
new MetadataCandidate(
id.Value.ToString(CultureInfo.InvariantCulture),
GetString(item, "name") ?? "—",
YearFrom(GetString(item, "first_air_date")),
GetString(item, TitleField(movie)) ?? "—",
YearFrom(GetString(item, DateField(movie))),
GetString(item, "overview"),
PosterUrl(GetString(item, "poster_path")),
// Поиск идёт по /search/tv, поэтому здесь всегда сериалы: полнометражки TMDb
// живут в отдельном пространстве идентификаторов и пока не поддержаны.
ShowKind.Series
movie ? ShowKind.Single : ShowKind.Series
)
);
}
return list;
}
/// <summary>
/// Среди чего искать. Сериал — это <see cref="ShowKind.Series"/>, всё остальное (полнометражка,
/// ролик-врезка) ищется среди фильмов: у ролика единственная «серия», и сезонов у него нет.
/// </summary>
private static bool IsMovie(ShowKind kind) => kind != ShowKind.Series;
private static string Segment(bool movie) => movie ? "movie" : "tv";
// У фильмов и сериалов TMDb называет одни и те же вещи по-разному.
private static string TitleField(bool movie) => movie ? "title" : "name";
private static string DateField(bool movie) => movie ? "release_date" : "first_air_date";
public async Task<ShowMetadata?> GetShowAsync(
string externalId,
ShowKind kind,
CancellationToken cancellationToken
)
{
// content_ratings подвешиваем к тому же запросу — отдельного обращения к API не требуется.
var movie = IsMovie(kind);
// Сертификацию подвешиваем к тому же запросу — отдельного обращения к API не требуется.
// У фильмов и сериалов она лежит в разных разделах, поэтому и append, и разбор разные.
var url =
$"{Tmdb.BaseUrl}/tv/{externalId}?api_key={Tmdb.ApiKey}&language={_options.Language}"
+ "&append_to_response=content_ratings";
$"{Tmdb.BaseUrl}/{Segment(movie)}/{externalId}"
+ $"?api_key={Tmdb.ApiKey}&language={_options.Language}"
+ $"&append_to_response={(movie ? "release_dates" : "content_ratings")}";
using var doc = await TryGetAsync(httpFactory, url, cancellationToken);
if (doc is null)
return null;
var root = doc.RootElement;
return new ShowMetadata(
externalId,
GetString(root, "name") ?? "—",
YearFrom(GetString(root, "first_air_date")),
GetString(root, TitleField(movie)) ?? "—",
YearFrom(GetString(root, DateField(movie))),
GetString(root, "overview"),
PosterUrl(GetString(root, "poster_path")),
GenresFrom(root),
CertificationFrom(root)
movie ? MovieCertification(root) : TvCertification(root)
);
}
/// <summary>Код прокатного релиза в справочнике типов релизов TMDb.</summary>
private const int TheatricalReleaseType = 3;
/// <summary>
/// Возрастная сертификация из <c>content_ratings</c>: перебираем страны в порядке приоритета
/// (<see cref="TmdbOptions.CertificationCountries"/>) и берём первую непустую. Отдаём написание
/// источника как есть — приводит его к шкале приложение (ContentRating).
///
/// Пустая строка в <c>rating</c> — обычное дело: страна в списке есть, а рейтинга у неё нет,
/// и такую запись надо пропускать, а не считать ответом.
/// Сертификация сериала: в <c>content_ratings</c> на страну приходится одна запись, рейтинг
/// лежит прямо в ней.
/// </summary>
private string? CertificationFrom(JsonElement root)
private string? TvCertification(JsonElement root) =>
FirstCertification(root, "content_ratings", item => Trimmed(GetString(item, "rating")));
/// <summary>
/// Сертификация фильма: в <c>release_dates</c> на страну приходится список релизов, и
/// <c>certification</c> проставлена не у всех. Прокатный релиз предпочитаем прочим — цифровые и
/// телевизионные переиздания иногда несут собственную, более позднюю маркировку.
/// </summary>
private string? MovieCertification(JsonElement root) =>
FirstCertification(
root,
"release_dates",
item =>
{
if (
!item.TryGetProperty("release_dates", out var releases)
|| releases.ValueKind != JsonValueKind.Array
)
return null;
string? anyRelease = null;
foreach (var release in releases.EnumerateArray())
{
if (Trimmed(GetString(release, "certification")) is not { } certification)
continue;
if (GetInt(release, "type") == TheatricalReleaseType)
return certification;
anyRelease ??= certification;
}
return anyRelease;
}
);
/// <summary>
/// Общая часть обеих форм: перебираем страны в порядке приоритета
/// (<see cref="TmdbOptions.CertificationCountries"/>) и отдаём первое непустое значение,
/// которое <paramref name="select"/> вытащит из записи страны. Написание источника не трогаем —
/// приводит его к шкале приложение (ContentRating).
///
/// Пустое значение — обычное дело: страна в списке есть, а сертификации у неё нет, и такую
/// запись надо пропускать, а не считать ответом.
/// </summary>
private string? FirstCertification(
JsonElement root,
string section,
Func<JsonElement, string?> select
)
{
if (
!root.TryGetProperty("content_ratings", out var ratings)
|| !ratings.TryGetProperty("results", out var results)
!root.TryGetProperty(section, out var block)
|| !block.TryGetProperty("results", out var results)
|| results.ValueKind != JsonValueKind.Array
)
return null;
@@ -108,14 +171,17 @@ public sealed class TmdbMetadataProvider(
)
continue;
if (GetString(item, "rating") is { Length: > 0 } rating && rating.Trim().Length > 0)
return rating.Trim();
if (select(item) is { } value)
return value;
}
}
return null;
}
private static string? Trimmed(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary>
/// Жанры из ответа TMDb в порядке источника: на каждый — сначала идентификатор
/// (<c>tmdb:28</c>), затем локализованное название. Идентификатор точнее, название — запасной