using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using Halo.Core.Metadata;
using Microsoft.Extensions.Logging;
namespace Halo.Metadata;
/// Fragt TMDB nach Titeln, Beschreibungen und Bildern.
///
///
///
/// Ein Fehlschlag ist kein Abbruch. Antwortet TMDB nicht — kein Netz,
/// Kontingent erschöpft, Wartung —, kommt null zurück und der Titel
/// bleibt beim Dateinamen. Ein Scan, der an einem fremden Dienst scheitert,
/// wäre der falsche Entwurf: die Mediathek steht dann leer da, obwohl die
/// Dateien tadellos vorhanden sind.
///
///
/// Ein falscher Schlüssel ist dagegen sehr wohl etwas, das jemand erfahren
/// muss — sonst sucht man stundenlang, warum keine Bilder erscheinen.
/// Deshalb nur dafür eine Ausnahme.
///
///
public sealed class TmdbClient(
HttpClient http,
ITmdbSettingsSource einstellungen,
ILogger log) : IMetadataProvider
{
public const string BaseAddress = "https://api.themoviedb.org/3/";
/// w500 für Plakate, w1280 für Hintergründe — genug für jeden Bildschirm.
///
///
/// Die Originalgrösse wäre bei einem Bestand mit tausend Titeln mehrere
/// Gigabyte Cache, ohne dass es auf einem Telefon je sichtbar würde.
///
private const string PosterSize = "w500";
private const string BackdropSize = "w1280";
private const string StillSize = "w300";
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
/// Ein Lese-Token ist ein JWT und fängt immer so an.
///
///
/// Damit beides funktioniert, was auf TMDBs Kontoseite steht. Dort
/// liegen zwei Dinge nebeneinander — „API Key" und „API Read Access
/// Token" —, und wer eines davon kopiert, soll nicht erst herausfinden
/// müssen, welches gemeint war.
///
private const string JwtAnfang = "eyJ";
public async Task IsConfiguredAsync(CancellationToken ct = default) =>
!string.IsNullOrWhiteSpace((await einstellungen.GetAsync(ct)).ApiKey);
public async Task FindMovieAsync(string title, int? year, CancellationToken ct = default)
{
var treffer = await SuchenAsync("search/movie", title,
year is { } j ? ("year", j.ToString()) : null, m => m.Title, ct);
return treffer is null ? null : await MitImdb(Als(treffer, title), "movie", ct);
}
public async Task> SearchMoviesAsync(
string title, int? year, CancellationToken ct = default)
{
var alle = await AlleAsync("search/movie", title,
year is { } j ? ("year", j.ToString()) : null, ct);
return alle.Select(m => Als(m, title)).ToList();
}
public async Task GetMovieAsync(int providerId, CancellationToken ct = default)
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
var film = await HolenAsync(
$"movie/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}", einstellung, ct);
return film is null ? null : await MitImdb(Als(film, ""), "movie", ct);
}
private static MovieMetadata Als(TmdbMovie roh, string rückfall) => new(
roh.Id, roh.Title ?? rückfall, JahrAus(roh.ReleaseDate), Leer(roh.Overview),
Bild(roh.PosterPath, PosterSize), Bild(roh.BackdropPath, BackdropSize));
public async Task FindSeriesAsync(string title, int? year, CancellationToken ct = default)
{
var treffer = await SuchenAsync("search/tv", title,
year is { } j ? ("first_air_date_year", j.ToString()) : null, s => s.Name, ct);
return treffer is null ? null : await MitImdb(Als(treffer, title), "tv", ct);
}
public async Task> SearchSeriesAsync(
string title, int? year, CancellationToken ct = default)
{
var alle = await AlleAsync("search/tv", title,
year is { } j ? ("first_air_date_year", j.ToString()) : null, ct);
return alle.Select(s => Als(s, title)).ToList();
}
public async Task GetSeriesAsync(int providerId, CancellationToken ct = default)
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
var serie = await HolenAsync(
$"tv/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}", einstellung, ct);
return serie is null ? null : await MitImdb(Als(serie, ""), "tv", ct);
}
private static SeriesMetadata Als(TmdbSeries roh, string rückfall) => new(
roh.Id, roh.Name ?? rückfall, JahrAus(roh.FirstAirDate), Leer(roh.Overview),
Bild(roh.PosterPath, PosterSize), Bild(roh.BackdropPath, BackdropSize));
/// Holt die IMDb-Kennung nach — eine Anfrage je neuem Treffer.
///
///
/// Die Suche bei TMDB liefert sie nicht mit. Gebraucht wird sie für
/// die NFO-Datei: wer später auf ein anderes Werkzeug wechselt, findet
/// damit denselben Titel wieder, auch wenn das Werkzeug TMDB gar nicht
/// kennt.
///
/// Scheitert der Nachschlag, bleibt sie leer — das ist kein Grund, den
/// ganzen Treffer wegzuwerfen.
///
private async Task MitImdb(MovieMetadata film, string art, CancellationToken ct) =>
film with { ImdbId = await ImdbAsync(art, film.ProviderId, ct) };
private async Task MitImdb(SeriesMetadata serie, string art, CancellationToken ct) =>
serie with { ImdbId = await ImdbAsync(art, serie.ProviderId, ct) };
private async Task ImdbAsync(string art, int kennung, CancellationToken ct)
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
var fremde = await HolenAsync($"{art}/{kennung}/external_ids", einstellung, ct);
return Leer(fremde?.ImdbId);
}
public async Task FindEpisodeAsync(
int seriesProviderId, int season, int episode, CancellationToken ct = default)
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
var pfad = $"tv/{seriesProviderId}/season/{season}/episode/{episode}" +
$"?language={Uri.EscapeDataString(einstellung.Language)}";
var folge = await HolenAsync(pfad, einstellung, ct);
if (folge is null) return null;
return new EpisodeMetadata(Leer(folge.Name), Leer(folge.Overview), Bild(folge.StillPath, StillSize));
}
/// Die ganze Trefferliste — für die Auswahl von Hand.
private async Task> AlleAsync(
string pfad, string titel, (string Name, string Wert)? zusatz, CancellationToken ct) where T : class
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey) || string.IsNullOrWhiteSpace(titel)) return [];
var abfrage = $"{pfad}?language={Uri.EscapeDataString(einstellung.Language)}" +
$"&query={Uri.EscapeDataString(titel)}";
if (zusatz is { } z) abfrage += $"&{z.Name}={Uri.EscapeDataString(z.Wert)}";
var antwort = await HolenAsync>(abfrage, einstellung, ct);
// Gedeckelt: wer aus fünfzig Vorschlägen wählen soll, wählt gar nicht.
return antwort?.Results?.Take(10).ToList() ?? [];
}
private async Task SuchenAsync(
string pfad, string titel, (string Name, string Wert)? zusatz,
Func titelVon, CancellationToken ct) where T : class
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey) || string.IsNullOrWhiteSpace(titel)) return null;
var abfrage = $"{pfad}?language={Uri.EscapeDataString(einstellung.Language)}" +
$"&query={Uri.EscapeDataString(titel)}";
if (zusatz is { } z) abfrage += $"&{z.Name}={Uri.EscapeDataString(z.Wert)}";
var antwort = await HolenAsync>(abfrage, einstellung, ct);
var treffer = antwort?.Results;
if (treffer is null || treffer.Count == 0) return null;
// **Ein genau passender Name sticht den beliebtesten.**
//
// TMDB sortiert nach Beliebtheit. Ohne Jahresangabe führt das in die
// Irre: die Suche nach „Dark" lieferte „Dark Shadows" von 1966 statt
// der Serie von 2017 — am echten Dienst gemessen, nicht vermutet.
// Steht ein Treffer mit genau diesem Namen in der Liste, ist er
// gemeint.
//
// Nur bei Gleichheit, nicht bei Ähnlichkeit: eine eigene Bewertung zu
// bauen hiesse, TMDBs Rangfolge nachzubauen — mit weniger Daten.
var genau = treffer.FirstOrDefault(e =>
string.Equals(titelVon(e)?.Trim(), titel.Trim(), StringComparison.OrdinalIgnoreCase));
return genau ?? treffer[0];
}
/// Holt eine Antwort — mit Bearer, wo es geht.
///
///
/// Der Lese-Token wandert in die Kopfzeile, nicht in die Adresse.
/// Ein ?api_key=… steht in jedem Zugriffsprotokoll, in jedem
/// Reverse Proxy und in jeder Fehlermeldung, die eine Adresse zitiert.
/// Eine Kopfzeile tut das nicht. Dazu kann ein Lese-Token nichts ändern,
/// nur lesen — bei einem Schlüssel, der in einem offenen Projekt
/// mitgeliefert wird, ist das der Unterschied, auf den es ankommt.
///
/// Der ältere Schlüssel geht trotzdem: er kann nur in die Adresse.
///
private async Task HolenAsync(
string pfadMitAbfrage, TmdbSettings einstellung, CancellationToken ct) where T : class
{
var zugang = einstellung.ApiKey!;
var alsBearer = zugang.StartsWith(JwtAnfang, StringComparison.Ordinal);
var adresse = alsBearer
? pfadMitAbfrage
: pfadMitAbfrage + (pfadMitAbfrage.Contains('?') ? "&" : "?")
+ $"api_key={Uri.EscapeDataString(zugang)}";
try
{
using var anfrage = new HttpRequestMessage(HttpMethod.Get, adresse);
if (alsBearer) anfrage.Headers.Authorization = new("Bearer", zugang);
using var antwort = await http.SendAsync(anfrage, ct);
if (antwort.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
throw new MetadataCredentialsException(
"TMDB weist den hinterlegten Schlüssel zurück. Prüfe ihn in den Servereinstellungen.");
// 404 heisst bei TMDB schlicht „gibt es nicht" — etwa eine Folge,
// die dort noch nicht eingetragen ist.
if (antwort.StatusCode == HttpStatusCode.NotFound) return null;
if (!antwort.IsSuccessStatusCode)
{
log.LogWarning("TMDB antwortete mit {Code} auf {Pfad}.", (int)antwort.StatusCode, Gekürzt(adresse));
return null;
}
return await antwort.Content.ReadFromJsonSafeAsync(Json, ct);
}
catch (MetadataCredentialsException) { throw; }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception fehler)
{
log.LogWarning(fehler, "TMDB war für {Pfad} nicht erreichbar.", Gekürzt(adresse));
return null;
}
}
/// Der Pfad ohne den Schlüssel — der gehört in kein Protokoll.
private static string Gekürzt(string pfadMitAbfrage)
{
var index = pfadMitAbfrage.IndexOf("api_key=", StringComparison.Ordinal);
return index < 0 ? pfadMitAbfrage : pfadMitAbfrage[..index] + "api_key=…";
}
private static RemoteImage? Bild(string? pfad, string grösse) =>
string.IsNullOrWhiteSpace(pfad) ? null : new RemoteImage($"https://image.tmdb.org/t/p/{grösse}{pfad}");
private static string? Leer(string? wert) => string.IsNullOrWhiteSpace(wert) ? null : wert;
private static int? JahrAus(string? datum) =>
datum is { Length: >= 4 } && int.TryParse(datum[..4], out var jahr) ? jahr : null;
// Nur die Felder, die gebraucht werden. TMDB liefert dreissig weitere.
private sealed record TmdbSearch([property: JsonPropertyName("results")] List? Results);
private sealed record TmdbExternalIds([property: JsonPropertyName("imdb_id")] string? ImdbId);
private sealed record TmdbMovie(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("title")] string? Title,
[property: JsonPropertyName("release_date")] string? ReleaseDate,
[property: JsonPropertyName("overview")] string? Overview,
[property: JsonPropertyName("poster_path")] string? PosterPath,
[property: JsonPropertyName("backdrop_path")] string? BackdropPath);
private sealed record TmdbSeries(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("first_air_date")] string? FirstAirDate,
[property: JsonPropertyName("overview")] string? Overview,
[property: JsonPropertyName("poster_path")] string? PosterPath,
[property: JsonPropertyName("backdrop_path")] string? BackdropPath);
private sealed record TmdbEpisode(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("overview")] string? Overview,
[property: JsonPropertyName("still_path")] string? StillPath);
}
internal static class HttpContentExtensions
{
/// Liest JSON und wirft nicht, wenn Unsinn zurückkommt.
///
///
/// Ein Reverse Proxy oder ein Anmeldeportal im Hotel-WLAN antwortet mit
/// HTML und dem Status 200. Darauf mit einer Ausnahme zu reagieren hiesse,
/// den ganzen Scan an einem fremden Netz scheitern zu lassen.
///
public static async Task ReadFromJsonSafeAsync(
this HttpContent inhalt, JsonSerializerOptions optionen, CancellationToken ct) where T : class
{
try
{
await using var strom = await inhalt.ReadAsStreamAsync(ct);
return await JsonSerializer.DeserializeAsync(strom, optionen, ct);
}
catch (JsonException) { return null; }
}
}