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)), // Die Sprache, in der diese Antwort kam — der Server merkt sie // sich am Titel, damit später feststeht, was vorliegt. Language = 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)), // Die Sprache, in der diese Antwort kam — der Server merkt sie // sich am Titel, damit später feststeht, was vorliegt. Language = 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), einstellung.Language); } /// 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; } } }