Files
Flaro/server/src/Flaro.Metadata/TmdbClient.cs
T
ScarriffleandClaude Opus 5 aeaf6eb578 „highschool musical 3" fand TMDB nicht, „high school musical 3" schon
Drei gemeldete Fälle, alle an TMDB nachgemessen statt vermutet:

  highschool musical 3              0 Treffer
  high school musical 3             2 Treffer, der richtige zuerst
  Harry Potter im Orden des Phoenix 0 Treffer
  Harry Potter                     20 Treffer, der richtige darunter
  dr dolittle                      13 Treffer, der richtige zuerst

Der fehlende Punkt bei Dr. Dolittle war nie das Problem — diese Vermutung
ist in Suchvarianten.cs festgehalten, damit sie nicht wiederkommt.

Die Suche versucht jetzt der Reihe nach: den Titel wie er dasteht, dann
das längste Wort an jeder Stelle getrennt (von der Mitte nach aussen,
ohne Wörterbuch — „highschool" trifft nach 2 Versuchen, „jurassicpark"
nach 5), dann von hinten gekürzt, bis zwei Worte übrig sind. Höchstens
zehn Anfragen, und nur dann, wenn die erste gar nichts brachte.

Und die Treffer werden nach Ähnlichkeit zum gesuchten Titel sortiert,
nicht nach Beliebtheit. Über gemeinsame Worte ohne Akzente: so trennt
„Harry Potter" den „Orden des Phönix" von „Harry Potter: Fireplace", und
„Phoenix" findet „Phönix".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 19:13:24 +02:00

630 lines
29 KiB
C#

using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using Flaro.Core.Metadata;
using Microsoft.Extensions.Logging;
namespace Flaro.Metadata;
/// <summary>Fragt TMDB nach Titeln, Beschreibungen und Bildern.</summary>
///
/// <remarks>
/// <para>
/// <b>Ein Fehlschlag ist kein Abbruch.</b> Antwortet TMDB nicht — kein Netz,
/// Kontingent erschöpft, Wartung —, kommt <c>null</c> 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.
/// </para>
/// <para>
/// <b>Ein falscher Schlüssel ist dagegen sehr wohl etwas, das jemand erfahren
/// muss</b> — sonst sucht man stundenlang, warum keine Bilder erscheinen.
/// Deshalb nur dafür eine Ausnahme.
/// </para>
/// </remarks>
public sealed class TmdbClient(
HttpClient http,
ITmdbSettingsSource einstellungen,
ILogger<TmdbClient> log) : IMetadataProvider
{
public const string BaseAddress = "https://api.themoviedb.org/3/";
/// <summary>w500 für Plakate, w1280 für Hintergründe — genug für jeden Bildschirm.</summary>
///
/// <remarks>
/// Die Originalgrösse wäre bei einem Bestand mit tausend Titeln mehrere
/// Gigabyte Cache, ohne dass es auf einem Telefon je sichtbar würde.
/// </remarks>
private const string PosterSize = "w500";
private const string BackdropSize = "w1280";
private const string StillSize = "w300";
/// <summary>Porträts sind klein und viele — ein Kopf je Kachel.</summary>
private const string ProfileSize = "w185";
/// <summary>Schriftzüge sind breit und flach — w500 reicht auch gross.</summary>
private const string LogoSize = "w500";
/// <summary>Wie viele Darsteller je Titel übernommen werden.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
private const int HöchstensDarsteller = 20;
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
/// <summary>Ein Lese-Token ist ein JWT und fängt immer so an.</summary>
///
/// <remarks>
/// <b>Damit beides funktioniert, was auf TMDBs Kontoseite steht.</b> 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.
/// </remarks>
private const string JwtAnfang = "eyJ";
public async Task<bool> IsConfiguredAsync(CancellationToken ct = default) =>
!string.IsNullOrWhiteSpace((await einstellungen.GetAsync(ct)).ApiKey);
public async Task<MovieMetadata?> FindMovieAsync(string title, int? year, CancellationToken ct = default)
{
var treffer = await SuchenAsync<TmdbMovie>("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<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(
string title, int? year, CancellationToken ct = default)
{
foreach (var begriff in Suchvarianten.Fuer(title))
{
var alle = await AlleAsync<TmdbMovie>("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<MovieMetadata?> GetMovieAsync(int providerId, CancellationToken ct = default) =>
FilmHolenAsync(providerId, null, ct);
public Task<MovieMetadata?> GetMovieAsync(int providerId, string sprache, CancellationToken ct = default) =>
FilmHolenAsync(providerId, sprache, ct);
/// <summary>Der gemeinsame Weg — mit Sprache oder mit der des Servers.</summary>
private async Task<MovieMetadata?> 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<TmdbMovie>(
$"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<SeriesMetadata?> FindSeriesAsync(string title, int? year, CancellationToken ct = default)
{
var treffer = await SuchenAsync<TmdbSeries>("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<IReadOnlyList<SeriesMetadata>> SearchSeriesAsync(
string title, int? year, CancellationToken ct = default)
{
foreach (var begriff in Suchvarianten.Fuer(title))
{
var alle = await AlleAsync<TmdbSeries>("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<SeriesMetadata?> GetSeriesAsync(int providerId, CancellationToken ct = default) =>
SerieHolenAsync(providerId, null, ct);
public Task<SeriesMetadata?> GetSeriesAsync(int providerId, string sprache, CancellationToken ct = default) =>
SerieHolenAsync(providerId, sprache, ct);
private async Task<SeriesMetadata?> 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<TmdbSeries>(
$"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<EpisodeMetadata?> 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<TmdbEpisode>(pfad, einstellung, ct);
if (folge is null) return null;
return new EpisodeMetadata(Leer(folge.Name), Leer(folge.Overview), Bild(folge.StillPath, StillSize));
}
/// <summary>Die ganze Trefferliste — für die Auswahl von Hand.</summary>
private async Task<List<T>> AlleAsync<T>(
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<TmdbSearch<T>>(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<T?> SuchenAsync<T>(
string pfad, string titel, (string Name, string Wert)? zusatz,
Func<T, string?> 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<TmdbSearch<T>>(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];
}
/// <summary>Holt eine Antwort — mit Bearer, wo es geht.</summary>
///
/// <remarks>
/// <b>Der Lese-Token wandert in die Kopfzeile, nicht in die Adresse.</b>
/// Ein <c>?api_key=…</c> 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.
/// </remarks>
private async Task<T?> HolenAsync<T>(
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<T>(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;
}
}
/// <summary>Der Pfad ohne den Schlüssel — der gehört in kein Protokoll.</summary>
private static string Gekürzt(string pfadMitAbfrage)
{
var index = pfadMitAbfrage.IndexOf("api_key=", StringComparison.Ordinal);
return index < 0 ? pfadMitAbfrage : pfadMitAbfrage[..index] + "api_key=…";
}
/// <summary>Das Land aus einer Sprachangabe wie „de-DE".</summary>
///
/// <remarks>
/// 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.
/// </remarks>
private static string Land(string sprache)
{
var teile = sprache.Split('-');
return teile.Length > 1 && teile[1].Length == 2 ? teile[1].ToUpperInvariant() : "US";
}
/// <summary>„de" aus „de-DE".</summary>
private static string Sprachkürzel(string sprache) =>
sprache.Split('-')[0].ToLowerInvariant();
/// <summary>Der Schriftzug, möglichst in der eingestellten Sprache.</summary>
///
/// <remarks>
/// <b>Die Reihenfolge ist die Aussage.</b> 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.
/// </remarks>
/// <summary>Alle Bilder zu einem Titel — für die Auswahl von Hand.</summary>
///
/// <remarks>
/// <b>Die Liste kommt ohnehin mit.</b> <c>append_to_response=…,images</c>
/// hängt sie an den Einzelabruf, und bisher wurde alles ausser dem
/// Schriftzug weggeworfen. Die Auswahl kostet also keine zusätzliche
/// Anfrage.
/// </remarks>
public async Task<IReadOnlyList<Bildkandidat>> 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<TmdbBilderAntwort>(
$"{weg}/{providerId}/images?include_image_language={Uri.EscapeDataString(sprache)},en,null",
einstellung, ct);
if (antwort is null) return [];
var liste = new List<Bildkandidat>();
Sammeln(antwort.Posters, "poster", PosterSize);
Sammeln(antwort.Backdrops, "backdrop", BackdropSize);
Sammeln(antwort.Logos, "logo", "w500");
return liste;
void Sammeln(List<TmdbImage>? 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<TmdbImage>? Posters,
[property: JsonPropertyName("backdrops")] List<TmdbImage>? Backdrops,
[property: JsonPropertyName("logos")] List<TmdbImage>? 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<string>? Namen(List<TmdbGenre>? 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;
}
/// <summary>Darsteller, Regie und Drehbuch — in dieser Reihenfolge.</summary>
///
/// <remarks>
/// <b>Aus dem Stab nur zwei Rollen.</b> TMDB führt dort auch Beleuchter
/// und Caterer; eine Detailseite, die alle nennt, nennt nichts. Regie und
/// Drehbuch sind das, wonach jemand sucht.
/// </remarks>
private static IReadOnlyList<CreditMetadata>? Mitwirkende(TmdbCredits? quelle)
{
if (quelle is null) return null;
var liste = new List<CreditMetadata>();
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<T>([property: JsonPropertyName("results")] List<T>? 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<TmdbGenre>? 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<int>? EpisodeRunTime = null,
[property: JsonPropertyName("vote_average")] double? VoteAverage = null,
[property: JsonPropertyName("tagline")] string? Tagline = null,
[property: JsonPropertyName("genres")] List<TmdbGenre>? 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<TmdbImage>? Logos,
[property: JsonPropertyName("posters")] List<TmdbImage>? Posters = null,
[property: JsonPropertyName("backdrops")] List<TmdbImage>? 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<TmdbCast>? Cast,
[property: JsonPropertyName("crew")] List<TmdbCrew>? 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<TmdbReleaseCountry>? Results);
private sealed record TmdbReleaseCountry(
[property: JsonPropertyName("iso_3166_1")] string? Country,
[property: JsonPropertyName("release_dates")] List<TmdbRelease>? Releases);
private sealed record TmdbRelease(
[property: JsonPropertyName("certification")] string? Certification);
private sealed record TmdbContentRatings(
[property: JsonPropertyName("results")] List<TmdbContentRating>? 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
{
/// <summary>Liest JSON und wirft nicht, wenn Unsinn zurückkommt.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
public static async Task<T?> ReadFromJsonSafeAsync<T>(
this HttpContent inhalt, JsonSerializerOptions optionen, CancellationToken ct) where T : class
{
try
{
await using var strom = await inhalt.ReadAsStreamAsync(ct);
return await JsonSerializer.DeserializeAsync<T>(strom, optionen, ct);
}
catch (JsonException) { return null; }
}
}