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