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