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.
This commit is contained in:
@@ -9,6 +9,7 @@ using TeleWave.Application.Metadata.RefreshEpisodes;
|
|||||||
using TeleWave.Application.Metadata.SearchShows;
|
using TeleWave.Application.Metadata.SearchShows;
|
||||||
using TeleWave.Application.Metadata.SetShowPoster;
|
using TeleWave.Application.Metadata.SetShowPoster;
|
||||||
using TeleWave.Application.Metadata.UpdateShowMetadata;
|
using TeleWave.Application.Metadata.UpdateShowMetadata;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
@@ -50,12 +51,13 @@ public static class MetadataEndpoints
|
|||||||
private static async Task<IResult> Search(
|
private static async Task<IResult> Search(
|
||||||
string provider,
|
string provider,
|
||||||
string query,
|
string query,
|
||||||
|
ShowKind kind,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new SearchShowMetadataQuery(provider, query),
|
new SearchShowMetadataQuery(provider, query, kind),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
|
|||||||
@@ -1,20 +1,40 @@
|
|||||||
using TeleWave.Application.Metadata;
|
using TeleWave.Application.Metadata;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
|
||||||
namespace TeleWave.Application.Common.Interfaces;
|
namespace TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
/// <summary>Источник метаданных о шоу/сериях (TMDb, OMDb). Реализация ходит во внешний API.</summary>
|
/// <summary>
|
||||||
|
/// Источник метаданных о шоу/сериях (TMDb, OMDb). Реализация ходит во внешний API.
|
||||||
|
///
|
||||||
|
/// Тип шоу передаётся отдельным параметром, а не зашивается в <c>externalId</c>: у TMDb сериалы и
|
||||||
|
/// фильмы нумеруются в разных пространствах, и одно и то же число там означает разное. Хранить
|
||||||
|
/// в идентификаторе синтетический префикс не пришлось — тип и так известен на месте вызова, а
|
||||||
|
/// <see cref="Show.Kind"/> неизменяем, поэтому найденный при поиске идентификатор при любом
|
||||||
|
/// последующем обращении истолкуется тем же типом.
|
||||||
|
/// </summary>
|
||||||
public interface IMetadataProvider
|
public interface IMetadataProvider
|
||||||
{
|
{
|
||||||
/// <summary>Ключ источника: «tmdb», «omdb».</summary>
|
/// <summary>Ключ источника: «tmdb», «omdb».</summary>
|
||||||
string Key { get; }
|
string Key { get; }
|
||||||
|
|
||||||
|
/// <summary>Кандидаты по названию среди контента указанного типа.</summary>
|
||||||
Task<IReadOnlyList<MetadataCandidate>> SearchShowsAsync(
|
Task<IReadOnlyList<MetadataCandidate>> SearchShowsAsync(
|
||||||
string query,
|
string query,
|
||||||
|
ShowKind kind,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
Task<ShowMetadata?> GetShowAsync(string externalId, CancellationToken cancellationToken);
|
/// <summary>
|
||||||
|
/// Метаданные шоу. <paramref name="kind"/> брать из самого шоу, а не из внешнего запроса:
|
||||||
|
/// иначе клиент может заставить записать сериалу метаданные одноимённого фильма.
|
||||||
|
/// </summary>
|
||||||
|
Task<ShowMetadata?> GetShowAsync(
|
||||||
|
string externalId,
|
||||||
|
ShowKind kind,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Метаданные серии. Только для <see cref="ShowKind.Series"/> — у остальных серий нет.</summary>
|
||||||
Task<EpisodeMetadata?> GetEpisodeAsync(
|
Task<EpisodeMetadata?> GetEpisodeAsync(
|
||||||
string externalId,
|
string externalId,
|
||||||
int season,
|
int season,
|
||||||
|
|||||||
+9
-1
@@ -5,6 +5,7 @@ using TeleWave.Application.Common.Models;
|
|||||||
using TeleWave.Application.Library;
|
using TeleWave.Application.Library;
|
||||||
using TeleWave.Application.Library.Genres;
|
using TeleWave.Application.Library.Genres;
|
||||||
using TeleWave.Domain.Images;
|
using TeleWave.Domain.Images;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
|
||||||
namespace TeleWave.Application.Metadata.ApplyShowMetadata;
|
namespace TeleWave.Application.Metadata.ApplyShowMetadata;
|
||||||
|
|
||||||
@@ -27,11 +28,18 @@ public sealed class ApplyShowMetadataCommandHandler(
|
|||||||
if (show is null)
|
if (show is null)
|
||||||
return Result.Failure(ShowErrors.NotFound);
|
return Result.Failure(ShowErrors.NotFound);
|
||||||
|
|
||||||
|
// Ролики метаданными не обогащаются: у них нет ни года, ни постера, а сотня роликов
|
||||||
|
// в поиске только мешала бы. Раньше это держалось на том, что UI такой кнопки не рисует.
|
||||||
|
if (show.Kind == ShowKind.Interstitial)
|
||||||
|
return Result.Failure(MetadataErrors.NotForInterstitials);
|
||||||
|
|
||||||
var provider = resolver.Resolve(command.Provider);
|
var provider = resolver.Resolve(command.Provider);
|
||||||
if (provider is null)
|
if (provider is null)
|
||||||
return Result.Failure(MetadataErrors.ProviderNotAvailable);
|
return Result.Failure(MetadataErrors.ProviderNotAvailable);
|
||||||
|
|
||||||
var meta = await provider.GetShowAsync(command.ExternalId, cancellationToken);
|
// Тип берём у шоу, а не из команды: иначе клиент смог бы заставить TMDb отдать метаданные
|
||||||
|
// одноимённого фильма для сериала — идентификаторы у них независимые.
|
||||||
|
var meta = await provider.GetShowAsync(command.ExternalId, show.Kind, cancellationToken);
|
||||||
if (meta is null)
|
if (meta is null)
|
||||||
return Result.Failure(MetadataErrors.NotFound);
|
return Result.Failure(MetadataErrors.NotFound);
|
||||||
|
|
||||||
|
|||||||
+6
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Application.Library;
|
using TeleWave.Application.Library;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
|
||||||
namespace TeleWave.Application.Metadata.FindMissingEpisodes;
|
namespace TeleWave.Application.Metadata.FindMissingEpisodes;
|
||||||
|
|
||||||
@@ -23,6 +24,11 @@ public sealed class FindMissingEpisodesQueryHandler(
|
|||||||
if (show is null)
|
if (show is null)
|
||||||
return Result.Failure<MissingEpisodesReport>(ShowErrors.NotFound);
|
return Result.Failure<MissingEpisodesReport>(ShowErrors.NotFound);
|
||||||
|
|
||||||
|
// Сезоны и серии есть только у сериала: у полнометражки и ролика запрос к источнику ушёл бы
|
||||||
|
// в /tv/{id} с идентификатором фильма и вернул бы либо пусто, либо чужие данные.
|
||||||
|
if (show.Kind != ShowKind.Series)
|
||||||
|
return Result.Failure<MissingEpisodesReport>(MetadataErrors.EpisodesForSeriesOnly);
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(show.MetadataExternalId))
|
if (string.IsNullOrEmpty(show.MetadataExternalId))
|
||||||
return Result.Failure<MissingEpisodesReport>(MetadataErrors.NoLinkedSource);
|
return Result.Failure<MissingEpisodesReport>(MetadataErrors.NoLinkedSource);
|
||||||
|
|
||||||
|
|||||||
@@ -23,4 +23,14 @@ public static class MetadataErrors
|
|||||||
"Metadata.ProviderRequestFailed",
|
"Metadata.ProviderRequestFailed",
|
||||||
"Источник метаданных вернул ошибку — проверьте API-ключ."
|
"Источник метаданных вернул ошибку — проверьте API-ключ."
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public static readonly Error NotForInterstitials = Error.Validation(
|
||||||
|
"Metadata.NotForInterstitials",
|
||||||
|
"Ролики-врезки метаданными не обогащаются."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static readonly Error EpisodesForSeriesOnly = Error.Validation(
|
||||||
|
"Metadata.EpisodesForSeriesOnly",
|
||||||
|
"Серии есть только у сериала."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -4,6 +4,7 @@ using TeleWave.Application.Common.Interfaces;
|
|||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Application.Library;
|
using TeleWave.Application.Library;
|
||||||
using TeleWave.Domain.Images;
|
using TeleWave.Domain.Images;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
|
||||||
namespace TeleWave.Application.Metadata.RefreshEpisodes;
|
namespace TeleWave.Application.Metadata.RefreshEpisodes;
|
||||||
|
|
||||||
@@ -25,6 +26,10 @@ public sealed class RefreshShowEpisodesMetadataCommandHandler(
|
|||||||
if (show is null)
|
if (show is null)
|
||||||
return Result.Failure<int>(ShowErrors.NotFound);
|
return Result.Failure<int>(ShowErrors.NotFound);
|
||||||
|
|
||||||
|
// Серии есть только у сериала — см. FindMissingEpisodesQueryHandler.
|
||||||
|
if (show.Kind != ShowKind.Series)
|
||||||
|
return Result.Failure<int>(MetadataErrors.EpisodesForSeriesOnly);
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(show.MetadataExternalId))
|
if (string.IsNullOrEmpty(show.MetadataExternalId))
|
||||||
return Result.Failure<int>(MetadataErrors.NoLinkedSource);
|
return Result.Failure<int>(MetadataErrors.NoLinkedSource);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
|
||||||
namespace TeleWave.Application.Metadata.SearchShows;
|
namespace TeleWave.Application.Metadata.SearchShows;
|
||||||
|
|
||||||
public sealed record SearchShowMetadataQuery(string Provider, string Query)
|
/// <summary>
|
||||||
|
/// Поиск кандидатов в источнике. <paramref name="Kind"/> определяет, среди чего искать: у TMDb
|
||||||
|
/// сериалы и фильмы лежат в разных пространствах идентификаторов.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SearchShowMetadataQuery(string Provider, string Query, ShowKind Kind)
|
||||||
: IQuery<Result<IReadOnlyList<MetadataCandidate>>>;
|
: IQuery<Result<IReadOnlyList<MetadataCandidate>>>;
|
||||||
|
|||||||
+5
-1
@@ -23,7 +23,11 @@ public sealed class SearchShowMetadataQueryHandler(IMetadataProviderResolver res
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var results = await provider.SearchShowsAsync(query.Query, cancellationToken);
|
var results = await provider.SearchShowsAsync(
|
||||||
|
query.Query,
|
||||||
|
query.Kind,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
return Result.Success(results);
|
return Result.Success(results);
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||||
|
|||||||
@@ -20,13 +20,16 @@ public sealed class OmdbMetadataProvider(
|
|||||||
|
|
||||||
public async Task<IReadOnlyList<MetadataCandidate>> SearchShowsAsync(
|
public async Task<IReadOnlyList<MetadataCandidate>> SearchShowsAsync(
|
||||||
string query,
|
string query,
|
||||||
|
ShowKind kind,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// Тип в запросе не фиксируем: imdbID уникален и для фильма, и для сериала, а GetShowAsync
|
// Тип сужает выдачу, но не влияет на идентификатор: imdbID уникален и для фильма, и для
|
||||||
// ходит по нему же — значит полнометражки находятся и обогащаются тем же путём, без
|
// сериала, поэтому GetShowAsync ходит по нему одинаково — в отличие от TMDb, где tv и movie
|
||||||
// разделения пространств идентификаторов (в отличие от TMDb, где tv и movie нумеруются врозь).
|
// нумеруются врозь.
|
||||||
var url = $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&s={Uri.EscapeDataString(query)}";
|
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);
|
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 [];
|
||||||
@@ -38,9 +41,10 @@ public sealed class OmdbMetadataProvider(
|
|||||||
if (id is null)
|
if (id is null)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Отдельные серии в выдаче — шум: шоу из них не собирают, а список они забивают.
|
// Тип читаем из ответа, а не подставляем запрошенный: фильтр в запросе OMDb соблюдает,
|
||||||
var kind = KindFrom(Clean(GetString(item, "Type")));
|
// но отдельные серии (Type = episode) иногда просачиваются, а шоу из них не собирают.
|
||||||
if (kind is null)
|
var found = KindFrom(Clean(GetString(item, "Type")));
|
||||||
|
if (found is null)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
list.Add(
|
list.Add(
|
||||||
@@ -50,15 +54,17 @@ public sealed class OmdbMetadataProvider(
|
|||||||
YearFrom(GetString(item, "Year")),
|
YearFrom(GetString(item, "Year")),
|
||||||
null,
|
null,
|
||||||
Clean(GetString(item, "Poster")),
|
Clean(GetString(item, "Poster")),
|
||||||
kind
|
found
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Тип здесь не используется: imdbID однозначен для фильмов и сериалов одинаково.</summary>
|
||||||
public async Task<ShowMetadata?> GetShowAsync(
|
public async Task<ShowMetadata?> GetShowAsync(
|
||||||
string externalId,
|
string externalId,
|
||||||
|
ShowKind kind,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,11 +22,13 @@ public sealed class TmdbMetadataProvider(
|
|||||||
|
|
||||||
public async Task<IReadOnlyList<MetadataCandidate>> SearchShowsAsync(
|
public async Task<IReadOnlyList<MetadataCandidate>> SearchShowsAsync(
|
||||||
string query,
|
string query,
|
||||||
|
ShowKind kind,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
var movie = IsMovie(kind);
|
||||||
var url =
|
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)}";
|
+ $"&include_adult=false&query={Uri.EscapeDataString(query)}";
|
||||||
using var doc = await GetAsync(httpFactory, url, cancellationToken);
|
using var doc = await GetAsync(httpFactory, url, cancellationToken);
|
||||||
if (!doc.RootElement.TryGetProperty("results", out var results))
|
if (!doc.RootElement.TryGetProperty("results", out var results))
|
||||||
@@ -41,56 +43,117 @@ public sealed class TmdbMetadataProvider(
|
|||||||
list.Add(
|
list.Add(
|
||||||
new MetadataCandidate(
|
new MetadataCandidate(
|
||||||
id.Value.ToString(CultureInfo.InvariantCulture),
|
id.Value.ToString(CultureInfo.InvariantCulture),
|
||||||
GetString(item, "name") ?? "—",
|
GetString(item, TitleField(movie)) ?? "—",
|
||||||
YearFrom(GetString(item, "first_air_date")),
|
YearFrom(GetString(item, DateField(movie))),
|
||||||
GetString(item, "overview"),
|
GetString(item, "overview"),
|
||||||
PosterUrl(GetString(item, "poster_path")),
|
PosterUrl(GetString(item, "poster_path")),
|
||||||
// Поиск идёт по /search/tv, поэтому здесь всегда сериалы: полнометражки TMDb
|
movie ? ShowKind.Single : ShowKind.Series
|
||||||
// живут в отдельном пространстве идентификаторов и пока не поддержаны.
|
|
||||||
ShowKind.Series
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return list;
|
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(
|
public async Task<ShowMetadata?> GetShowAsync(
|
||||||
string externalId,
|
string externalId,
|
||||||
|
ShowKind kind,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// content_ratings подвешиваем к тому же запросу — отдельного обращения к API не требуется.
|
var movie = IsMovie(kind);
|
||||||
|
|
||||||
|
// Сертификацию подвешиваем к тому же запросу — отдельного обращения к API не требуется.
|
||||||
|
// У фильмов и сериалов она лежит в разных разделах, поэтому и append, и разбор разные.
|
||||||
var url =
|
var url =
|
||||||
$"{Tmdb.BaseUrl}/tv/{externalId}?api_key={Tmdb.ApiKey}&language={_options.Language}"
|
$"{Tmdb.BaseUrl}/{Segment(movie)}/{externalId}"
|
||||||
+ "&append_to_response=content_ratings";
|
+ $"?api_key={Tmdb.ApiKey}&language={_options.Language}"
|
||||||
|
+ $"&append_to_response={(movie ? "release_dates" : "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;
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
return new ShowMetadata(
|
return new ShowMetadata(
|
||||||
externalId,
|
externalId,
|
||||||
GetString(root, "name") ?? "—",
|
GetString(root, TitleField(movie)) ?? "—",
|
||||||
YearFrom(GetString(root, "first_air_date")),
|
YearFrom(GetString(root, DateField(movie))),
|
||||||
GetString(root, "overview"),
|
GetString(root, "overview"),
|
||||||
PosterUrl(GetString(root, "poster_path")),
|
PosterUrl(GetString(root, "poster_path")),
|
||||||
GenresFrom(root),
|
GenresFrom(root),
|
||||||
CertificationFrom(root)
|
movie ? MovieCertification(root) : TvCertification(root)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Код прокатного релиза в справочнике типов релизов TMDb.</summary>
|
||||||
|
private const int TheatricalReleaseType = 3;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Возрастная сертификация из <c>content_ratings</c>: перебираем страны в порядке приоритета
|
/// Сертификация сериала: в <c>content_ratings</c> на страну приходится одна запись, рейтинг
|
||||||
/// (<see cref="TmdbOptions.CertificationCountries"/>) и берём первую непустую. Отдаём написание
|
/// лежит прямо в ней.
|
||||||
/// источника как есть — приводит его к шкале приложение (ContentRating).
|
|
||||||
///
|
|
||||||
/// Пустая строка в <c>rating</c> — обычное дело: страна в списке есть, а рейтинга у неё нет,
|
|
||||||
/// и такую запись надо пропускать, а не считать ответом.
|
|
||||||
/// </summary>
|
/// </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 (
|
if (
|
||||||
!root.TryGetProperty("content_ratings", out var ratings)
|
!root.TryGetProperty(section, out var block)
|
||||||
|| !ratings.TryGetProperty("results", out var results)
|
|| !block.TryGetProperty("results", out var results)
|
||||||
|| results.ValueKind != JsonValueKind.Array
|
|| results.ValueKind != JsonValueKind.Array
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
@@ -108,14 +171,17 @@ public sealed class TmdbMetadataProvider(
|
|||||||
)
|
)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (GetString(item, "rating") is { Length: > 0 } rating && rating.Trim().Length > 0)
|
if (select(item) is { } value)
|
||||||
return rating.Trim();
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? Trimmed(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Жанры из ответа TMDb в порядке источника: на каждый — сначала идентификатор
|
/// Жанры из ответа TMDb в порядке источника: на каждый — сначала идентификатор
|
||||||
/// (<c>tmdb:28</c>), затем локализованное название. Идентификатор точнее, название — запасной
|
/// (<c>tmdb:28</c>), затем локализованное название. Идентификатор точнее, название — запасной
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NSubstitute;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Library.Genres;
|
||||||
|
using TeleWave.Application.Metadata;
|
||||||
|
using TeleWave.Application.Metadata.ApplyShowMetadata;
|
||||||
|
using TeleWave.Application.Tests.Support;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Tests.Metadata;
|
||||||
|
|
||||||
|
public class ApplyShowMetadataTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(ShowKind.Series)]
|
||||||
|
[InlineData(ShowKind.Single)]
|
||||||
|
public async Task PassesShowKindToProvider(ShowKind kind)
|
||||||
|
{
|
||||||
|
// Тип определяет, в какое пространство идентификаторов пойдёт TMDb, и берётся он у самого
|
||||||
|
// шоу, а не из команды: иначе запросом можно было бы записать сериалу метаданные
|
||||||
|
// одноимённого фильма.
|
||||||
|
var arranged = await ArrangeAsync(kind);
|
||||||
|
Respond(arranged.Provider, new ShowMetadata("42", "A", 2000, null, null));
|
||||||
|
|
||||||
|
var result = await ApplyAsync(arranged);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
await arranged
|
||||||
|
.Provider.Received(1)
|
||||||
|
.GetShowAsync("42", kind, Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AppliesRecognizedRating()
|
||||||
|
{
|
||||||
|
var arranged = await ArrangeAsync(ShowKind.Single);
|
||||||
|
Respond(arranged.Provider, new ShowMetadata("42", "A", 2000, null, null, null, "PG-13"));
|
||||||
|
|
||||||
|
await ApplyAsync(arranged);
|
||||||
|
|
||||||
|
Assert.Equal(ShowAudience.Pg13, await SavedAudienceAsync(arranged));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task KeepsRating_WhenSourceGivesNothingUsable()
|
||||||
|
{
|
||||||
|
// «Not Rated» — это отсутствие данных. Снять им проставленный рейтинг нельзя: обновление
|
||||||
|
// метаданных тихо открыло бы взрослому шоу дорогу в детское время.
|
||||||
|
var arranged = await ArrangeAsync(ShowKind.Single, ShowAudience.R);
|
||||||
|
Respond(arranged.Provider, new ShowMetadata("42", "A", 2000, null, null, null, "Not Rated"));
|
||||||
|
|
||||||
|
await ApplyAsync(arranged);
|
||||||
|
|
||||||
|
Assert.Equal(ShowAudience.R, await SavedAudienceAsync(arranged));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Fails_ForInterstitial()
|
||||||
|
{
|
||||||
|
var arranged = await ArrangeAsync(ShowKind.Interstitial);
|
||||||
|
|
||||||
|
Assert.False(await ApplyAsync(arranged));
|
||||||
|
await arranged
|
||||||
|
.Provider.DidNotReceive()
|
||||||
|
.GetShowAsync(
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<ShowKind>(),
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Respond(IMetadataProvider provider, ShowMetadata meta) =>
|
||||||
|
provider
|
||||||
|
.GetShowAsync("42", Arg.Any<ShowKind>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(meta);
|
||||||
|
|
||||||
|
private static async Task<bool> ApplyAsync(Arranged arranged)
|
||||||
|
{
|
||||||
|
await using var db = arranged.Fixture.New();
|
||||||
|
var handler = new ApplyShowMetadataCommandHandler(
|
||||||
|
db,
|
||||||
|
arranged.Resolver,
|
||||||
|
Substitute.For<IImageDownloader>(),
|
||||||
|
Substitute.For<IImageStore>(),
|
||||||
|
new GenreMatcher(db)
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(
|
||||||
|
new ApplyShowMetadataCommand(arranged.ShowId, "tmdb", "42"),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
// В обычном пути сохраняет UnitOfWorkBehavior; при прямом вызове хендлера его нет.
|
||||||
|
if (result.IsSuccess)
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return result.IsSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ShowAudience?> SavedAudienceAsync(Arranged arranged)
|
||||||
|
{
|
||||||
|
await using var db = arranged.Fixture.New();
|
||||||
|
return (await db.Shows.FirstAsync(s => s.Id == arranged.ShowId)).Audience;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Arranged(
|
||||||
|
TestDb Fixture,
|
||||||
|
IMetadataProvider Provider,
|
||||||
|
IMetadataProviderResolver Resolver,
|
||||||
|
Guid ShowId
|
||||||
|
);
|
||||||
|
|
||||||
|
private static async Task<Arranged> ArrangeAsync(ShowKind kind, ShowAudience? audience = null)
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var show = Show.Create("A", kind);
|
||||||
|
show.SetAudience(audience);
|
||||||
|
|
||||||
|
await using (var seed = fixture.New())
|
||||||
|
{
|
||||||
|
seed.Shows.Add(show);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider = Substitute.For<IMetadataProvider>();
|
||||||
|
var resolver = Substitute.For<IMetadataProviderResolver>();
|
||||||
|
resolver.Resolve("tmdb").Returns(provider);
|
||||||
|
|
||||||
|
return new Arranged(fixture, provider, resolver, show.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,29 @@ public class FindMissingEpisodesTests
|
|||||||
Assert.Equal([2, 3], s2.Missing);
|
Assert.Equal([2, 3], s2.Missing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Fails_ForNonSeries()
|
||||||
|
{
|
||||||
|
// У полнометражки сезонов нет, и запрос ушёл бы в /tv/{id} с идентификатором фильма.
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var show = Show.Create("A", ShowKind.Single);
|
||||||
|
show.ApplyMetadata("tmdb", "123", null, 2000, null);
|
||||||
|
await using (var seed = fixture.New())
|
||||||
|
{
|
||||||
|
seed.Shows.Add(show);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolver = Substitute.For<IMetadataProviderResolver>();
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var result = await new FindMissingEpisodesQueryHandler(db, resolver).Handle(
|
||||||
|
new FindMissingEpisodesQuery(show.Id),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Fails_WhenNoLinkedSource()
|
public async Task Fails_WhenNoLinkedSource()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
|||||||
const searchTerm = originalName.trim() || name.trim()
|
const searchTerm = originalName.trim() || name.trim()
|
||||||
|
|
||||||
const search = useMutation({
|
const search = useMutation({
|
||||||
mutationFn: () => searchMetadata(effectiveProvider, searchTerm),
|
mutationFn: () => searchMetadata(effectiveProvider, searchTerm, show.kind),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setResults(data)
|
setResults(data)
|
||||||
setSearched(true)
|
setSearched(true)
|
||||||
|
|||||||
@@ -73,8 +73,9 @@ export function getMetadataProviders() {
|
|||||||
return apiRequest<string[]>('/admin/metadata/providers')
|
return apiRequest<string[]>('/admin/metadata/providers')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchMetadata(provider: string, query: string) {
|
/** `kind` определяет, среди чего искать: у TMDb сериалы и фильмы — разные пространства id. */
|
||||||
const q = new URLSearchParams({ provider, query })
|
export function searchMetadata(provider: string, query: string, kind: ShowKind) {
|
||||||
|
const q = new URLSearchParams({ provider, query, kind })
|
||||||
return apiRequest<MetadataCandidate[]>(`/admin/metadata/search?${q.toString()}`)
|
return apiRequest<MetadataCandidate[]>(`/admin/metadata/search?${q.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user