Enhance TMDb metadata handling with bearer token support and update .env.example documentation
Updated the TMDb metadata provider to support both API key and bearer token authentication methods, improving flexibility in API requests. Refactored the GetAsync and TryGetAsync methods to accept an optional bearer token parameter. Additionally, clarified the .env.example file to provide detailed instructions on using TMDb's authentication methods, ensuring users understand how to configure their API access correctly.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace TeleWave.Infrastructure.Metadata;
|
||||
@@ -5,15 +6,23 @@ namespace TeleWave.Infrastructure.Metadata;
|
||||
/// <summary>Общие для провайдеров метаданных хелперы: HTTP-загрузка JSON и чтение полей.</summary>
|
||||
internal static class MetadataJson
|
||||
{
|
||||
/// <summary>GET+parse через клиент "metadata"; бросает при не-2xx/сетевой ошибке (для поиска — показать сбой).</summary>
|
||||
/// <summary>
|
||||
/// GET+parse через клиент "metadata"; бросает при не-2xx/сетевой ошибке (для поиска — показать
|
||||
/// сбой). <paramref name="bearerToken"/> задаётся, когда источник авторизует не ключом в query,
|
||||
/// а заголовком (у TMDb так работает токен v4).
|
||||
/// </summary>
|
||||
public static async Task<JsonDocument> GetAsync(
|
||||
IHttpClientFactory httpFactory,
|
||||
string url,
|
||||
CancellationToken cancellationToken
|
||||
CancellationToken cancellationToken,
|
||||
string? bearerToken = null
|
||||
)
|
||||
{
|
||||
var client = httpFactory.CreateClient("metadata");
|
||||
using var response = await client.GetAsync(url, cancellationToken);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
if (!string.IsNullOrEmpty(bearerToken))
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
@@ -23,12 +32,13 @@ internal static class MetadataJson
|
||||
public static async Task<JsonDocument?> TryGetAsync(
|
||||
IHttpClientFactory httpFactory,
|
||||
string url,
|
||||
CancellationToken cancellationToken
|
||||
CancellationToken cancellationToken,
|
||||
string? bearerToken = null
|
||||
)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await GetAsync(httpFactory, url, cancellationToken);
|
||||
return await GetAsync(httpFactory, url, cancellationToken, bearerToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
when (ex is HttpRequestException or JsonException or TaskCanceledException)
|
||||
|
||||
@@ -20,6 +20,22 @@ public sealed class TmdbMetadataProvider(
|
||||
|
||||
private TmdbOptions Tmdb => _options.Tmdb;
|
||||
|
||||
/// <summary>
|
||||
/// TMDb принимает два вида учётных данных, и различаются они формой, а не настройкой: ключ v3 —
|
||||
/// 32 hex-символа и едет параметром <c>api_key</c>, токен v4 — JWT (три части через точку) и
|
||||
/// едет заголовком <c>Authorization: Bearer</c>. Точка в hex-ключе невозможна, поэтому по ней
|
||||
/// и различаем — оператору не нужно заводить ещё одну переменную и гадать, какая к какому ключу.
|
||||
/// </summary>
|
||||
private bool UsesBearer => Tmdb.ApiKey.Contains('.', StringComparison.Ordinal);
|
||||
|
||||
private string? BearerToken => UsesBearer ? Tmdb.ApiKey : null;
|
||||
|
||||
/// <summary>Начало строки запроса: авторизация (если она в query) и язык — они есть у всех обращений.</summary>
|
||||
private string BaseQuery =>
|
||||
UsesBearer
|
||||
? $"?language={_options.Language}"
|
||||
: $"?api_key={Tmdb.ApiKey}&language={_options.Language}";
|
||||
|
||||
public async Task<IReadOnlyList<MetadataCandidate>> SearchShowsAsync(
|
||||
string query,
|
||||
ShowKind kind,
|
||||
@@ -28,9 +44,9 @@ public sealed class TmdbMetadataProvider(
|
||||
{
|
||||
var movie = IsMovie(kind);
|
||||
var url =
|
||||
$"{Tmdb.BaseUrl}/search/{Segment(movie)}?api_key={Tmdb.ApiKey}&language={_options.Language}"
|
||||
$"{Tmdb.BaseUrl}/search/{Segment(movie)}{BaseQuery}"
|
||||
+ $"&include_adult=false&query={Uri.EscapeDataString(query)}";
|
||||
using var doc = await GetAsync(httpFactory, url, cancellationToken);
|
||||
using var doc = await GetAsync(httpFactory, url, cancellationToken, BearerToken);
|
||||
if (!doc.RootElement.TryGetProperty("results", out var results))
|
||||
return [];
|
||||
|
||||
@@ -78,10 +94,9 @@ public sealed class TmdbMetadataProvider(
|
||||
// Сертификацию подвешиваем к тому же запросу — отдельного обращения к API не требуется.
|
||||
// У фильмов и сериалов она лежит в разных разделах, поэтому и append, и разбор разные.
|
||||
var url =
|
||||
$"{Tmdb.BaseUrl}/{Segment(movie)}/{externalId}"
|
||||
+ $"?api_key={Tmdb.ApiKey}&language={_options.Language}"
|
||||
$"{Tmdb.BaseUrl}/{Segment(movie)}/{externalId}{BaseQuery}"
|
||||
+ $"&append_to_response={(movie ? "release_dates" : "content_ratings")}";
|
||||
using var doc = await TryGetAsync(httpFactory, url, cancellationToken);
|
||||
using var doc = await TryGetAsync(httpFactory, url, cancellationToken, BearerToken);
|
||||
if (doc is null)
|
||||
return null;
|
||||
var root = doc.RootElement;
|
||||
@@ -210,10 +225,8 @@ public sealed class TmdbMetadataProvider(
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var url =
|
||||
$"{Tmdb.BaseUrl}/tv/{externalId}/season/{season}/episode/{episode}"
|
||||
+ $"?api_key={Tmdb.ApiKey}&language={_options.Language}";
|
||||
using var doc = await TryGetAsync(httpFactory, url, cancellationToken);
|
||||
var url = $"{Tmdb.BaseUrl}/tv/{externalId}/season/{season}/episode/{episode}{BaseQuery}";
|
||||
using var doc = await TryGetAsync(httpFactory, url, cancellationToken, BearerToken);
|
||||
if (doc is null)
|
||||
return null;
|
||||
var root = doc.RootElement;
|
||||
@@ -231,10 +244,8 @@ public sealed class TmdbMetadataProvider(
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var url =
|
||||
$"{Tmdb.BaseUrl}/tv/{externalId}/season/{season}"
|
||||
+ $"?api_key={Tmdb.ApiKey}&language={_options.Language}";
|
||||
using var doc = await TryGetAsync(httpFactory, url, cancellationToken);
|
||||
var url = $"{Tmdb.BaseUrl}/tv/{externalId}/season/{season}{BaseQuery}";
|
||||
using var doc = await TryGetAsync(httpFactory, url, cancellationToken, BearerToken);
|
||||
if (
|
||||
doc is null
|
||||
|| !doc.RootElement.TryGetProperty("episodes", out var episodes)
|
||||
@@ -249,9 +260,8 @@ public sealed class TmdbMetadataProvider(
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var url =
|
||||
$"{Tmdb.BaseUrl}/tv/{externalId}?api_key={Tmdb.ApiKey}&language={_options.Language}";
|
||||
using var doc = await TryGetAsync(httpFactory, url, cancellationToken);
|
||||
var url = $"{Tmdb.BaseUrl}/tv/{externalId}{BaseQuery}";
|
||||
using var doc = await TryGetAsync(httpFactory, url, cancellationToken, BearerToken);
|
||||
if (
|
||||
doc is null
|
||||
|| !doc.RootElement.TryGetProperty("seasons", out var seasons)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Infrastructure.Metadata;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Metadata;
|
||||
|
||||
/// <summary>
|
||||
/// TMDb принимает и ключ v3 (в query), и токен v4 (в заголовке). Способ выбирается по форме самого
|
||||
/// значения, поэтому проверяем именно то, что уходит в запрос.
|
||||
/// </summary>
|
||||
public class TmdbAuthTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task V3Key_GoesToQuery()
|
||||
{
|
||||
var handler = new CapturingHandler();
|
||||
var provider = ProviderWith("fa38966b51f0043827fa89a5e42a0afe", handler);
|
||||
|
||||
await provider.GetSeasonNumbersAsync("123", CancellationToken.None);
|
||||
|
||||
var request = handler.Last!;
|
||||
Assert.Contains("api_key=fa38966b51f0043827fa89a5e42a0afe", request.RequestUri!.Query);
|
||||
Assert.Null(request.Headers.Authorization);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task V4Token_GoesToAuthorizationHeader()
|
||||
{
|
||||
var handler = new CapturingHandler();
|
||||
var token = "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJ0ZWxld2F2ZSJ9.c2lnbmF0dXJl";
|
||||
var provider = ProviderWith(token, handler);
|
||||
|
||||
await provider.GetSeasonNumbersAsync("123", CancellationToken.None);
|
||||
|
||||
var request = handler.Last!;
|
||||
Assert.DoesNotContain("api_key", request.RequestUri!.Query);
|
||||
Assert.Equal("Bearer", request.Headers.Authorization?.Scheme);
|
||||
Assert.Equal(token, request.Headers.Authorization?.Parameter);
|
||||
}
|
||||
|
||||
private static TmdbMetadataProvider ProviderWith(string apiKey, HttpMessageHandler handler) =>
|
||||
new(
|
||||
new SingleClientFactory(handler),
|
||||
Options.Create(new MetadataOptions { Tmdb = new TmdbOptions { ApiKey = apiKey } })
|
||||
);
|
||||
|
||||
private sealed class CapturingHandler : HttpMessageHandler
|
||||
{
|
||||
public HttpRequestMessage? Last { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
Last = request;
|
||||
return Task.FromResult(
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SingleClientFactory(HttpMessageHandler handler) : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => new(handler, disposeHandler: false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user