Enhance TMDb metadata handling with bearer token support and update .env.example documentation
ci / build-backend (push) Successful in 2m11s
ci / build-frontend (push) Failing after 16s
ci / tests (push) Skipped
ci / sonar (push) Skipped

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:
Leonid Pershin
2026-07-27 03:43:21 +03:00
parent 8841059070
commit 67e8b941bb
4 changed files with 117 additions and 22 deletions
+4 -1
View File
@@ -90,8 +90,11 @@ Media__LoudnessTargetLufs=-16
# Bumpers__TemplateVersion=1
# ── Метаданные шоу/серий (TMDb / OMDb) ─────────────────────────────────────
# Ключи бесплатные: TMDb — themoviedb.org (Settings → API, v3 key), OMDb — omdbapi.com.
# Ключи бесплатные: TMDb — themoviedb.org (Settings → API), OMDb — omdbapi.com.
# Без ключа источник просто не показывается в админке (ручной режим работает всегда).
# TMDb принимает оба вида учётных данных, различать их не нужно — подставляй что есть:
# «API Key (v3 auth)» — 32 hex-символа, уходит параметром api_key;
# «API Read Access Token (v4 auth)» — длинный JWT с точками, уходит заголовком Bearer.
Metadata__Language=ru-RU
Metadata__Tmdb__ApiKey=
Metadata__Omdb__ApiKey=
@@ -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);
}
}