Files
Flaro/server/src/Flaro.Metadata/OmdbClient.cs
T
ScarriffleandClaude Opus 5 af2b844d65 IMDb, Rotten Tomatoes und Metacritic neben der TMDB-Wertung
IMDbs eigene Schnittstelle kostet Geld, Rotten Tomatoes vergibt Zugänge nur
an Partner, Letterboxd hat gar keine offene. OMDb liefert alle drei in
einer Antwort, über die IMDb-Kennung, die ohnehin schon an jedem Film steht.

**Eine Tabelle und kein Feld je Quelle.** `Movie.Rating` ist ein Skalar
ohne Quellenangabe, und das war richtig, solange es eine Quelle gab. Für
drei weitere wären es drei Spalten — und bei der vierten wieder eine
Migration. Jetzt ist jede Quelle eine Zeile, je Titel und Quelle eine, mit
eindeutigem Index darauf: ein zweiter Lauf schreibt um statt anzuhäufen.

**Zwei Werte je Bewertung, und beide gebraucht.** Der normierte Wert zum
Vergleichen und Sortieren, die Anzeige zum Hinschreiben. Rotten Tomatoes in
„8,7 von 10" umzurechnen wäre eine Lüge über die Quelle — dort steht 87 %,
und genau so kennt man es.

**Wieder keine zweite Metadatenquelle.** `IBewertungsquelle` liefert
Bewertungen, sonst nichts. Bei Titel und Beschreibung wären zwei Anbieter
zwei Wahrheiten; bei einer Bewertung ist jede Quelle eine eigene Zahl, und
da sind mehrere kein Widerspruch, sondern der Sinn der Sache.

**Ohne Kennung wird nicht gefragt.** OMDb kennt nur den Weg über die
IMDb-Kennung; nach einem Titel zu suchen hiesse, denselben Zuordnungsfehler
ein zweites Mal zu riskieren. Und auf Unbekanntes antwortet OMDb mit 200
und `Response: "False"` — das als Treffer durchzulassen legte eine leere
Bewertung ab, die danach als „keine Wertung" dastünde.

Angezeigt wird mit Kürzel statt Stern: vier gleich aussehende Zahlen
nebeneinander sagen nicht, woher sie kommen. Kein Logo — die Marken von
IMDb und Rotten Tomatoes mitzuliefern hiesse, fremde Zeichen auszuliefern.

Kein mitgelieferter Schlüssel, aus demselben Grund wie bei fanart.tv: OMDb
begrenzt auf tausend Abrufe am Tag je Schlüssel. Einzutragen unter
Einstellungen → Übersicht → Metadaten.

740 Tests grün, davon acht neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 21:26:58 +02:00

138 lines
5.0 KiB
C#

using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Flaro.Core.Metadata;
using Microsoft.Extensions.Logging;
namespace Flaro.Metadata;
/// <summary>Woher der Schlüssel für OMDb kommt.</summary>
public interface IOmdbSettingsSource
{
Task<string?> SchluesselAsync(CancellationToken ct = default);
}
/// <summary>Bewertungen von IMDb, Rotten Tomatoes und Metacritic.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum OMDb und nicht die Quellen selbst.</b> IMDbs eigene
/// Schnittstelle kostet Geld, Rotten Tomatoes vergibt Zugänge nur an
/// Partner, und Letterboxd hat gar keine offene. OMDb liefert alle drei in
/// einer Antwort, über die IMDb-Kennung, die Flaro ohnehin schon führt.
/// </para>
/// <para>
/// <b>Zwei Werte je Bewertung, und beide gebraucht.</b> Der normierte Wert
/// zum Vergleichen, die Anzeige zum Hinschreiben. Rotten Tomatoes in „8,7
/// von 10" umzurechnen wäre eine Lüge über die Quelle — dort steht 87 %,
/// und genau das kennt man von dort.
/// </para>
/// <para>
/// <b>Ohne Schlüssel geschieht nichts.</b> OMDb verlangt einen
/// persönlichen, kostenlosen; einen fremden mitzuliefern hiesse, dessen
/// Tageskontingent zu verbrauchen.
/// </para>
/// </remarks>
public sealed class OmdbClient(
HttpClient http,
IOmdbSettingsSource einstellungen,
ILogger<OmdbClient> log) : IBewertungsquelle
{
public const string BaseAddress = "https://www.omdbapi.com/";
public async Task<bool> EingerichtetAsync(CancellationToken ct = default) =>
!string.IsNullOrWhiteSpace(await einstellungen.SchluesselAsync(ct));
public async Task<IReadOnlyList<Fremdwertung>> HolenAsync(
string imdbId, CancellationToken ct = default)
{
var schluessel = await einstellungen.SchluesselAsync(ct);
if (string.IsNullOrWhiteSpace(schluessel) || string.IsNullOrWhiteSpace(imdbId)) return [];
OmdbAntwort? antwort;
try
{
using var ergebnis = await http.GetAsync(
$"?i={Uri.EscapeDataString(imdbId)}&apikey={Uri.EscapeDataString(schluessel)}", ct);
if (!ergebnis.IsSuccessStatusCode)
{
log.LogDebug("OMDb antwortete mit {Code} für {Kennung}.", ergebnis.StatusCode, imdbId);
return [];
}
antwort = await ergebnis.Content.ReadFromJsonOderNull<OmdbAntwort>(ct);
}
catch (Exception fehler) when (fehler is HttpRequestException or TaskCanceledException or JsonException)
{
// Eine fremde Quelle, die hakt, darf keinen Scan anhalten.
log.LogDebug(fehler, "OMDb war für {Kennung} nicht zu erreichen.", imdbId);
return [];
}
// OMDb antwortet auf Unbekanntes mit 200 und `Response: "False"`.
if (antwort is null || !string.Equals(antwort.Response, "True", StringComparison.OrdinalIgnoreCase))
return [];
var liste = new List<Fremdwertung>();
foreach (var r in antwort.Ratings ?? [])
{
var quelle = Quelle(r.Source);
if (quelle is null || string.IsNullOrWhiteSpace(r.Value)) continue;
if (Lesen(quelle, r.Value) is not { } wert) continue;
liste.Add(new Fremdwertung(quelle, wert, Anzeige(quelle, r.Value)));
}
return liste;
}
/// <summary>Die Namen, die OMDb benutzt, auf unsere Kennungen.</summary>
private static string? Quelle(string? name) => name switch
{
"Internet Movie Database" => "imdb",
"Rotten Tomatoes" => "rottenTomatoes",
"Metacritic" => "metacritic",
// Käme eine vierte dazu, stünde sie hier — bis dahin wird sie
// übergangen statt mit falschem Namen abgelegt.
_ => null,
};
/// <summary>Bringt „8.7/10", „87%" und „62/100" auf dieselbe Skala.</summary>
///
/// <remarks>
/// Nur zum Vergleichen. Angezeigt wird, was die Quelle sagt — siehe
/// <see cref="Anzeige"/>.
/// </remarks>
private static double? Lesen(string quelle, string roh)
{
var zahl = roh.Split('/')[0].TrimEnd('%');
if (!double.TryParse(zahl, NumberStyles.Float, CultureInfo.InvariantCulture, out var wert)) return null;
return quelle switch
{
"imdb" => wert,
"rottenTomatoes" => wert / 10,
"metacritic" => wert / 10,
_ => null,
};
}
/// <summary>Was dastehen soll — in der Form, die man von der Quelle kennt.</summary>
private static string Anzeige(string quelle, string roh) => quelle switch
{
"imdb" => roh.Split('/')[0].Replace('.', ','),
"rottenTomatoes" => roh,
_ => roh.Split('/')[0],
};
private sealed record OmdbAntwort(
[property: JsonPropertyName("Response")] string? Response,
[property: JsonPropertyName("Ratings")] List<OmdbWertung>? Ratings);
private sealed record OmdbWertung(
[property: JsonPropertyName("Source")] string? Source,
[property: JsonPropertyName("Value")] string? Value);
}