using System.Globalization; 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; /// Провайдер метаданных OMDb (omdbapi.com, данные IMDb). Требует API-ключ. public sealed class OmdbMetadataProvider( IHttpClientFactory httpFactory, IOptions options ) : IMetadataProvider { private readonly OmdbOptions _omdb = options.Value.Omdb; public string Key => "omdb"; public async Task> SearchShowsAsync( string query, ShowKind kind, CancellationToken cancellationToken ) { // Тип сужает выдачу, но не влияет на идентификатор: 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 []; var list = new List(); foreach (var item in search.EnumerateArray()) { var id = Clean(GetString(item, "imdbID")); if (id is null) continue; // Тип читаем из ответа, а не подставляем запрошенный: фильтр в запросе OMDb соблюдает, // но отдельные серии (Type = episode) иногда просачиваются, а шоу из них не собирают. var found = KindFrom(Clean(GetString(item, "Type"))); if (found is null) continue; list.Add( new MetadataCandidate( id, Clean(GetString(item, "Title")) ?? "—", YearFrom(GetString(item, "Year")), null, Clean(GetString(item, "Poster")), found ) ); } return list; } /// Тип здесь не используется: imdbID однозначен для фильмов и сериалов одинаково. public async Task GetShowAsync( string externalId, ShowKind kind, CancellationToken cancellationToken ) { var url = $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&i={Uri.EscapeDataString(externalId)}"; using var doc = await TryGetAsync(httpFactory, url, cancellationToken); if (doc is null || !IsResponseTrue(doc.RootElement)) return null; var root = doc.RootElement; return new ShowMetadata( externalId, Clean(GetString(root, "Title")) ?? "—", YearFrom(GetString(root, "Year")), Clean(GetString(root, "Plot")), Clean(GetString(root, "Poster")), GenresFrom(Clean(GetString(root, "Genre"))), // «Rated» приезжает только в ответе по id — то есть ровно здесь, без лишнего запроса. // Отдаём как есть: приводить к шкале будет приложение (ContentRating). Clean(GetString(root, "Rated")) ); } /// Тип из выдачи OMDb: «series»/«movie»; «episode» и незнакомое — null (в шоу не годятся). private static ShowKind? KindFrom(string? type) => type?.ToLowerInvariant() switch { "series" => ShowKind.Series, "movie" => ShowKind.Single, _ => null, }; /// OMDb отдаёт жанры одной строкой через запятую: «Action, Sci-Fi». private static List GenresFrom(string? value) => string.IsNullOrWhiteSpace(value) ? [] : value .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToList(); public async Task GetEpisodeAsync( string externalId, int season, int episode, CancellationToken cancellationToken ) { var url = $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&i={Uri.EscapeDataString(externalId)}" + $"&Season={season}&Episode={episode}"; using var doc = await TryGetAsync(httpFactory, url, cancellationToken); if (doc is null || !IsResponseTrue(doc.RootElement)) return null; var root = doc.RootElement; return new EpisodeMetadata( Clean(GetString(root, "Title")) ?? "—", Clean(GetString(root, "Plot")), Clean(GetString(root, "Poster")), DateFrom(GetString(root, "Released")) ); } public async Task GetSeasonEpisodeCountAsync( string externalId, int season, CancellationToken cancellationToken ) { var url = $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&i={Uri.EscapeDataString(externalId)}&Season={season}"; using var doc = await TryGetAsync(httpFactory, url, cancellationToken); if ( doc is null || !IsResponseTrue(doc.RootElement) || !doc.RootElement.TryGetProperty("Episodes", out var episodes) || episodes.ValueKind != JsonValueKind.Array ) return null; return episodes.GetArrayLength(); } /// /// OMDb перечня сезонов не отдаёт — только их количество (totalSeasons, строкой) в ответе /// по id. Считаем сезоны сплошной нумерацией с единицы: разрывов в ней у OMDb не бывает. /// public async Task> GetSeasonNumbersAsync( string externalId, CancellationToken cancellationToken ) { var url = $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&i={Uri.EscapeDataString(externalId)}"; using var doc = await TryGetAsync(httpFactory, url, cancellationToken); if (doc is null || !IsResponseTrue(doc.RootElement)) return []; var total = Clean(GetString(doc.RootElement, "totalSeasons")); return int.TryParse(total, CultureInfo.InvariantCulture, out var count) && count > 0 ? Enumerable.Range(1, count).ToList() : []; } private static bool IsResponseTrue(JsonElement root) => GetString(root, "Response") is { } r && r.Equals("True", StringComparison.OrdinalIgnoreCase); /// OMDb отдаёт «N/A» вместо отсутствующих значений — приводим к null. private static string? Clean(string? value) => string.IsNullOrWhiteSpace(value) || value == "N/A" ? null : value; private static DateOnly? DateFrom(string? released) => DateTime.TryParse(released, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt) ? DateOnly.FromDateTime(dt) : null; }