using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using Flaro.Core.Metadata;
using Microsoft.Extensions.Logging;
namespace Flaro.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";
/// Porträts sind klein und viele — ein Kopf je Kachel.
private const string ProfileSize = "w185";
/// Schriftzüge sind breit und flach — w500 reicht auch gross.
private const string LogoSize = "w500";
/// Wie viele Darsteller je Titel übernommen werden.
///
///
/// TMDB nennt bei grossen Produktionen über hundert. Eine Besetzungsliste,
/// die man scrollen muss, bis die Statisten kommen, hilft niemandem — und
/// jede Zeile ist ein Porträt, das heruntergeladen und abgelegt wird.
///
private const int HöchstensDarsteller = 20;
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);
if (treffer is null) return null;
// **Der Treffer der Suche taugt nur als Kennung.** `search/movie`
// liefert weder Laufzeit noch Genres noch Besetzung; dafür braucht es
// den Einzelabruf. Der kostet eine Anfrage — dieselbe, die vorher
// ohnehin für die IMDb-Kennung fällig war.
return await GetMovieAsync(treffer.Id, ct) ?? Als(treffer, title);
}
public async Task> SearchMoviesAsync(
string title, int? year, CancellationToken ct = default)
{
foreach (var begriff in Suchvarianten.Fuer(title))
{
var alle = await AlleAsync("search/movie", begriff,
year is { } j ? ("year", j.ToString()) : null, ct);
if (alle.Count == 0) continue;
// **Nach Ähnlichkeit zum gesuchten Titel, nicht nach Beliebtheit.**
// Gefunden wird unter Umständen über einen gekürzten Begriff;
// dann liefert TMDB zwanzig Treffer, und der richtige ist der mit
// den meisten gemeinsamen Worten.
return alle
.OrderByDescending(m => Suchvarianten.Aehnlichkeit(title, m.Title, m.OriginalTitle))
.Select(m => Als(m, title))
.ToList();
}
return [];
}
public Task GetMovieAsync(int providerId, CancellationToken ct = default) =>
FilmHolenAsync(providerId, null, ct);
public Task GetMovieAsync(int providerId, string sprache, CancellationToken ct = default) =>
FilmHolenAsync(providerId, sprache, ct);
/// Der gemeinsame Weg — mit Sprache oder mit der des Servers.
private async Task FilmHolenAsync(
int providerId, string? sprache, CancellationToken ct)
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
if (sprache is { Length: > 0 }) einstellung = einstellung with { Language = sprache };
// **Alles in einer Anfrage.** `append_to_response` hängt Besetzung,
// Fremdkennungen und Freigaben an den Einzelabruf. Drei getrennte
// Anfragen je Titel wären bei vierhundert Filmen zwölfhundert — und
// TMDB begrenzt, wie oft man fragen darf.
var film = await HolenAsync(
$"movie/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}" +
"&append_to_response=credits,external_ids,release_dates,images" +
// **Sprachlos ist erwünscht.** Viele Schriftzüge tragen gar keine
// Sprache (`null`), weil sie keinen Text enthalten, der übersetzt
// werden müsste. Ohne diese Angabe liefert TMDB sie nicht mit.
$"&include_image_language={Uri.EscapeDataString(Sprachkürzel(einstellung.Language))},en,null",
einstellung, ct);
// **Kennung null gibt es bei TMDB nicht.** Antwortet etwas anderes
// als der erwartete Einzelabruf — ein Anmeldeportal, ein Proxy, eine
// Trefferliste —, liest der Dekoder daraus eine Null. Das als Treffer
// durchzulassen hiesse, einen Film mit der Kennung 0 anzulegen.
if (film is null or { Id: 0 }) return null;
return Als(film, "") with
{
ImdbId = Leer(film.ExternalIds?.ImdbId),
RuntimeMinutes = film.Runtime > 0 ? film.Runtime : null,
Rating = film.VoteAverage > 0 ? film.VoteAverage : null,
Tagline = Leer(film.Tagline),
ContentRating = Freigabe(film.ReleaseDates, Land(einstellung.Language)),
Genres = Namen(film.Genres),
Credits = Mitwirkende(film.Credits),
Logo = Schriftzug(film.Images, Sprachkürzel(einstellung.Language)),
};
}
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))
{
OriginalTitle = Leer(roh.OriginalTitle),
};
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);
if (treffer is null) return null;
return await GetSeriesAsync(treffer.Id, ct) ?? Als(treffer, title);
}
public async Task> SearchSeriesAsync(
string title, int? year, CancellationToken ct = default)
{
foreach (var begriff in Suchvarianten.Fuer(title))
{
var alle = await AlleAsync("search/tv", begriff,
year is { } j ? ("first_air_date_year", j.ToString()) : null, ct);
if (alle.Count == 0) continue;
return alle
.OrderByDescending(s => Suchvarianten.Aehnlichkeit(title, s.Name, s.OriginalName))
.Select(s => Als(s, title))
.ToList();
}
return [];
}
public Task GetSeriesAsync(int providerId, CancellationToken ct = default) =>
SerieHolenAsync(providerId, null, ct);
public Task GetSeriesAsync(int providerId, string sprache, CancellationToken ct = default) =>
SerieHolenAsync(providerId, sprache, ct);
private async Task SerieHolenAsync(
int providerId, string? sprache, CancellationToken ct)
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
if (sprache is { Length: > 0 }) einstellung = einstellung with { Language = sprache };
var serie = await HolenAsync(
$"tv/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}" +
"&append_to_response=credits,external_ids,content_ratings,images" +
$"&include_image_language={Uri.EscapeDataString(Sprachkürzel(einstellung.Language))},en,null",
einstellung, ct);
if (serie is null or { Id: 0 }) return null;
return Als(serie, "") with
{
ImdbId = Leer(serie.ExternalIds?.ImdbId),
// Die übliche Länge einer Folge, nicht die der ganzen Serie.
RuntimeMinutes = serie.EpisodeRunTime?.FirstOrDefault(l => l > 0),
Rating = serie.VoteAverage > 0 ? serie.VoteAverage : null,
Tagline = Leer(serie.Tagline),
ContentRating = Freigabe(serie.ContentRatings, Land(einstellung.Language)),
Genres = Namen(serie.Genres),
Credits = Mitwirkende(serie.Credits),
Logo = Schriftzug(serie.Images, Sprachkürzel(einstellung.Language)),
};
}
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))
{
OriginalTitle = Leer(roh.OriginalName),
};
// Der eigene Abruf von `external_ids` ist entfallen: die Kennung hängt
// jetzt über `append_to_response` am Einzelabruf. Das spart eine Anfrage
// je Titel — bei vierhundert Filmen vierhundert.
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=…";
}
/// Das Land aus einer Sprachangabe wie „de-DE".
///
///
/// Freigaben sind national: die FSK gilt in Deutschland, die MPAA in den
/// USA, und TMDB führt beide nebeneinander. Steht kein Land dabei, ist
/// „US" die Angabe, die am häufigsten gefüllt ist — besser als keine.
///
private static string Land(string sprache)
{
var teile = sprache.Split('-');
return teile.Length > 1 && teile[1].Length == 2 ? teile[1].ToUpperInvariant() : "US";
}
/// „de" aus „de-DE".
private static string Sprachkürzel(string sprache) =>
sprache.Split('-')[0].ToLowerInvariant();
/// Der Schriftzug, möglichst in der eingestellten Sprache.
///
///
/// Die Reihenfolge ist die Aussage. Erst die eigene Sprache — ein
/// deutscher Filmtitel in deutscher Schrift —, dann der ohne Sprache
/// (meist ein reines Bildzeichen, überall richtig), dann Englisch. Erst
/// innerhalb einer Stufe entscheidet die Bewertung bei TMDB, weil es dort
/// um Bildqualität geht und nicht mehr um die Sprache.
///
/// Alle Bilder zu einem Titel — für die Auswahl von Hand.
///
///
/// Die Liste kommt ohnehin mit. append_to_response=…,images
/// hängt sie an den Einzelabruf, und bisher wurde alles ausser dem
/// Schriftzug weggeworfen. Die Auswahl kostet also keine zusätzliche
/// Anfrage.
///
public async Task> BilderAsync(
int providerId, bool serie, CancellationToken ct = default)
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return [];
var sprache = Sprachkürzel(einstellung.Language);
var weg = serie ? "tv" : "movie";
var antwort = await HolenAsync(
$"{weg}/{providerId}/images?include_image_language={Uri.EscapeDataString(sprache)},en,null",
einstellung, ct);
if (antwort is null) return [];
var liste = new List();
Sammeln(antwort.Posters, "poster", PosterSize);
Sammeln(antwort.Backdrops, "backdrop", BackdropSize);
Sammeln(antwort.Logos, "logo", "w500");
return liste;
void Sammeln(List? bilder, string art, string groesse)
{
foreach (var b in (bilder ?? []).Where(b => !string.IsNullOrWhiteSpace(b.FilePath))
.OrderByDescending(b => b.VoteAverage ?? 0)
.Take(24))
{
liste.Add(new Bildkandidat(
art, Bild(b.FilePath, groesse)!.Url,
Bild(b.FilePath, "w185")!.Url,
string.IsNullOrEmpty(b.Language) ? null : b.Language,
b.VoteAverage));
}
}
}
private sealed record TmdbBilderAntwort(
[property: JsonPropertyName("posters")] List? Posters,
[property: JsonPropertyName("backdrops")] List? Backdrops,
[property: JsonPropertyName("logos")] List? Logos);
private static RemoteImage? Schriftzug(TmdbImages? bilder, string sprache)
{
var alle = bilder?.Logos?.Where(l => !string.IsNullOrWhiteSpace(l.FilePath)).ToList();
if (alle is not { Count: > 0 }) return null;
var beste = alle
.OrderBy(l => string.Equals(l.Language, sprache, StringComparison.OrdinalIgnoreCase) ? 0
: string.IsNullOrEmpty(l.Language) ? 1
: string.Equals(l.Language, "en", StringComparison.OrdinalIgnoreCase) ? 2
: 3)
.ThenByDescending(l => l.VoteAverage ?? 0)
.First();
return Bild(beste.FilePath, LogoSize);
}
private static string? Freigabe(TmdbReleaseDates? quelle, string land) =>
Leer(quelle?.Results?
.FirstOrDefault(r => string.Equals(r.Country, land, StringComparison.OrdinalIgnoreCase))?
.Releases?
.Select(r => r.Certification)
.FirstOrDefault(c => !string.IsNullOrWhiteSpace(c)));
private static string? Freigabe(TmdbContentRatings? quelle, string land) =>
Leer(quelle?.Results?
.FirstOrDefault(r => string.Equals(r.Country, land, StringComparison.OrdinalIgnoreCase))?
.Rating);
private static IReadOnlyList? Namen(List? genres)
{
var liste = genres?
.Select(g => g.Name?.Trim())
.Where(n => !string.IsNullOrEmpty(n))
.Select(n => n!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return liste is { Count: > 0 } ? liste : null;
}
/// Darsteller, Regie und Drehbuch — in dieser Reihenfolge.
///
///
/// Aus dem Stab nur zwei Rollen. TMDB führt dort auch Beleuchter
/// und Caterer; eine Detailseite, die alle nennt, nennt nichts. Regie und
/// Drehbuch sind das, wonach jemand sucht.
///
private static IReadOnlyList? Mitwirkende(TmdbCredits? quelle)
{
if (quelle is null) return null;
var liste = new List();
foreach (var d in (quelle.Cast ?? []).OrderBy(c => c.Order ?? int.MaxValue).Take(HöchstensDarsteller))
{
if (string.IsNullOrWhiteSpace(d.Name)) continue;
liste.Add(new CreditMetadata(
d.Id, d.Name.Trim(), CreditKind.Actor, Leer(d.Character),
Bild(d.ProfilePath, ProfileSize), d.Order ?? liste.Count));
}
var stab = 0;
foreach (var m in quelle.Crew ?? [])
{
var rolle = m.Job?.Trim() switch
{
"Director" => CreditKind.Director,
"Writer" or "Screenplay" or "Story" => CreditKind.Writer,
_ => (CreditKind?)null,
};
if (rolle is not { } art || string.IsNullOrWhiteSpace(m.Name)) continue;
// Dieselbe Person steht im Stab mehrfach, wenn sie mehrere
// Aufgaben hatte — „Drehbuch" und „Story" sind bei TMDB zwei
// Zeilen und auf einer Detailseite ein Name.
if (liste.Any(e => e.ProviderId == m.Id && e.Kind == art)) continue;
liste.Add(new CreditMetadata(
m.Id, m.Name.Trim(), art, null, Bild(m.ProfilePath, ProfileSize), stab++));
}
return liste.Count > 0 ? liste : null;
}
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("original_title")] string? OriginalTitle,
[property: JsonPropertyName("release_date")] string? ReleaseDate,
[property: JsonPropertyName("overview")] string? Overview,
[property: JsonPropertyName("poster_path")] string? PosterPath,
[property: JsonPropertyName("backdrop_path")] string? BackdropPath,
// Die folgenden liefert nur der Einzelabruf, nicht die Suche.
[property: JsonPropertyName("runtime")] int? Runtime = null,
[property: JsonPropertyName("vote_average")] double? VoteAverage = null,
[property: JsonPropertyName("tagline")] string? Tagline = null,
[property: JsonPropertyName("genres")] List? Genres = null,
[property: JsonPropertyName("credits")] TmdbCredits? Credits = null,
[property: JsonPropertyName("external_ids")] TmdbExternalIds? ExternalIds = null,
[property: JsonPropertyName("release_dates")] TmdbReleaseDates? ReleaseDates = null,
[property: JsonPropertyName("images")] TmdbImages? Images = null);
private sealed record TmdbSeries(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("original_name")] string? OriginalName,
[property: JsonPropertyName("first_air_date")] string? FirstAirDate,
[property: JsonPropertyName("overview")] string? Overview,
[property: JsonPropertyName("poster_path")] string? PosterPath,
[property: JsonPropertyName("backdrop_path")] string? BackdropPath,
[property: JsonPropertyName("episode_run_time")] List? EpisodeRunTime = null,
[property: JsonPropertyName("vote_average")] double? VoteAverage = null,
[property: JsonPropertyName("tagline")] string? Tagline = null,
[property: JsonPropertyName("genres")] List? Genres = null,
[property: JsonPropertyName("credits")] TmdbCredits? Credits = null,
[property: JsonPropertyName("external_ids")] TmdbExternalIds? ExternalIds = null,
[property: JsonPropertyName("content_ratings")] TmdbContentRatings? ContentRatings = null,
[property: JsonPropertyName("images")] TmdbImages? Images = null);
private sealed record TmdbGenre([property: JsonPropertyName("name")] string? Name);
private sealed record TmdbImages(
[property: JsonPropertyName("logos")] List? Logos,
[property: JsonPropertyName("posters")] List? Posters = null,
[property: JsonPropertyName("backdrops")] List? Backdrops = null);
private sealed record TmdbImage(
[property: JsonPropertyName("file_path")] string? FilePath,
[property: JsonPropertyName("iso_639_1")] string? Language,
[property: JsonPropertyName("vote_average")] double? VoteAverage);
private sealed record TmdbCredits(
[property: JsonPropertyName("cast")] List? Cast,
[property: JsonPropertyName("crew")] List? Crew);
private sealed record TmdbCast(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("character")] string? Character,
[property: JsonPropertyName("profile_path")] string? ProfilePath,
[property: JsonPropertyName("order")] int? Order);
private sealed record TmdbCrew(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("job")] string? Job,
[property: JsonPropertyName("profile_path")] string? ProfilePath);
private sealed record TmdbReleaseDates(
[property: JsonPropertyName("results")] List? Results);
private sealed record TmdbReleaseCountry(
[property: JsonPropertyName("iso_3166_1")] string? Country,
[property: JsonPropertyName("release_dates")] List? Releases);
private sealed record TmdbRelease(
[property: JsonPropertyName("certification")] string? Certification);
private sealed record TmdbContentRatings(
[property: JsonPropertyName("results")] List? Results);
private sealed record TmdbContentRating(
[property: JsonPropertyName("iso_3166_1")] string? Country,
[property: JsonPropertyName("rating")] string? Rating);
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; }
}
}