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