From 67e8b941bbc52cf115996babdbc721b529dab03e Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 27 Jul 2026 03:43:21 +0300 Subject: [PATCH] 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. --- .env.example | 5 +- .../Metadata/MetadataJson.cs | 20 ++++-- .../Metadata/TmdbMetadataProvider.cs | 42 ++++++----- .../Metadata/TmdbAuthTests.cs | 72 +++++++++++++++++++ 4 files changed, 117 insertions(+), 22 deletions(-) create mode 100644 backend/tests/TeleWave.Application.Tests/Metadata/TmdbAuthTests.cs diff --git a/.env.example b/.env.example index f93428c..9d34be3 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/backend/src/TeleWave.Infrastructure/Metadata/MetadataJson.cs b/backend/src/TeleWave.Infrastructure/Metadata/MetadataJson.cs index ddd5f7f..bcf2d69 100644 --- a/backend/src/TeleWave.Infrastructure/Metadata/MetadataJson.cs +++ b/backend/src/TeleWave.Infrastructure/Metadata/MetadataJson.cs @@ -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; /// Общие для провайдеров метаданных хелперы: HTTP-загрузка JSON и чтение полей. internal static class MetadataJson { - /// GET+parse через клиент "metadata"; бросает при не-2xx/сетевой ошибке (для поиска — показать сбой). + /// + /// GET+parse через клиент "metadata"; бросает при не-2xx/сетевой ошибке (для поиска — показать + /// сбой). задаётся, когда источник авторизует не ключом в query, + /// а заголовком (у TMDb так работает токен v4). + /// public static async Task 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 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) diff --git a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs index 346c241..a985f97 100644 --- a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs +++ b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs @@ -20,6 +20,22 @@ public sealed class TmdbMetadataProvider( private TmdbOptions Tmdb => _options.Tmdb; + /// + /// TMDb принимает два вида учётных данных, и различаются они формой, а не настройкой: ключ v3 — + /// 32 hex-символа и едет параметром api_key, токен v4 — JWT (три части через точку) и + /// едет заголовком Authorization: Bearer. Точка в hex-ключе невозможна, поэтому по ней + /// и различаем — оператору не нужно заводить ещё одну переменную и гадать, какая к какому ключу. + /// + private bool UsesBearer => Tmdb.ApiKey.Contains('.', StringComparison.Ordinal); + + private string? BearerToken => UsesBearer ? Tmdb.ApiKey : null; + + /// Начало строки запроса: авторизация (если она в query) и язык — они есть у всех обращений. + private string BaseQuery => + UsesBearer + ? $"?language={_options.Language}" + : $"?api_key={Tmdb.ApiKey}&language={_options.Language}"; + public async Task> 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) diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/TmdbAuthTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/TmdbAuthTests.cs new file mode 100644 index 0000000..c28eb60 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Metadata/TmdbAuthTests.cs @@ -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; + +/// +/// TMDb принимает и ключ v3 (в query), и токен v4 (в заголовке). Способ выбирается по форме самого +/// значения, поэтому проверяем именно то, что уходит в запрос. +/// +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 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); + } +}