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;
}