OMDb führt im Feld `Ratings` nur eine Zahl je Dienst, und bei Rotten
Tomatoes ist das die der Kritik. Die erweiterten Felder gibt es, sie
stehen aber seit Jahren auf `N/A`; Jellyfin fragt sie an und liest sie
nicht einmal aus — deren Antwortklasse kennt `tomatoUserMeter` gar nicht.
Dort hat den Publikumswert also niemand.
Auf der Seite selbst steht er. Gemessen an `/m/the_matrix`: im Quelltext
liegt `"audienceScore":{… "score":"85" …}` neben `"criticsScore"`, dazu
ein `application/ld+json` mit Titel und Erscheinungsdatum; bei Serien
unter `/tv/…` dasselbe, und ein unbekannter Weg antwortet mit 404. Eine
Prüfung mit `[Explicit]` geht einmal wirklich hinaus und bestätigt es:
`rottenTomatoes 83%, rottenTomatoesAudience 85%`.
Das ist Auslesen einer fremden Webseite, und es hat seinen Preis. Eine
offene Suche gibt es nicht — `/napi/search` antwortet mit 401 —, also
wird der Weg aus dem Titel geraten und hinterher am Jahr geprüft: auf der
Seite steht keine IMDb-Kennung, und `/m/the_lion_king` ist der von 1994.
Weicht das Jahr um mehr als eines ab, wird verworfen; lieber keine Zahl
als die eines fremden Films. Der Originaltitel kommt zuerst dran, sonst
fände ein deutscher Bestand dort fast nichts. Ohne Jahr zuerst, weil der
Normalfall den blanken Namen trägt — mit Jahr zuerst kostete jeder Titel
eine Anfrage zu viel.
Deshalb ein Schalter, und der steht aus, bis jemand ihn umlegt. Dazu eine
Bremse: höchstens eine Anfrage je Sekunde, prozessweit. Achthundert Titel
in einem Schwall von einer Adresse wären eine Zumutung und würden
gesperrt. Der Scan dauert dadurch länger; das ist der Preis fürs
Mitbenutzen.
Die Probe unter Einstellungen → Metadaten fragt die Quelle mit, und dort
ist sie mehr als eine Höflichkeit: wird die Seite umgebaut, merkt man es
genau da und sonst nirgends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
252 lines
9.2 KiB
C#
252 lines
9.2 KiB
C#
using System.Net;
|
|
using Flaro.Core.Metadata;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace Flaro.Metadata.Tests;
|
|
|
|
/// <summary>Bewertungen von IMDb, Rotten Tomatoes und Metacritic.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Warum OMDb und nicht die Quellen selbst.</b> 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 Flaro ohnehin schon führt.
|
|
/// </remarks>
|
|
public class OmdbTests
|
|
{
|
|
/// <summary>Wonach gefragt wird — bei OMDb zählt allein die Kennung.</summary>
|
|
private static Wertungsfrage Frage(string imdbId) => new(imdbId, "Probe", 1999);
|
|
|
|
private static OmdbClient Client(Attrappe attrappe, string? schluessel = "ein-schluessel") =>
|
|
new(new HttpClient(attrappe) { BaseAddress = new Uri(OmdbClient.BaseAddress) },
|
|
new FesterOmdbSchluessel(schluessel),
|
|
NullLogger<OmdbClient>.Instance);
|
|
|
|
private const string Antwort = """
|
|
{"Title":"Probe","Response":"True",
|
|
"Ratings":[
|
|
{"Source":"Internet Movie Database","Value":"7.4/10"},
|
|
{"Source":"Rotten Tomatoes","Value":"87%"},
|
|
{"Source":"Metacritic","Value":"62/100"}]}
|
|
""";
|
|
|
|
[Test]
|
|
public async Task Ohne_Schluessel_geht_keine_Anfrage_hinaus()
|
|
{
|
|
var attrappe = Attrappe.MitJson(Antwort);
|
|
|
|
var wertungen = await Client(attrappe, schluessel: null).HolenAsync(Frage("tt0000001"));
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(wertungen, Is.Empty);
|
|
Assert.That(attrappe.Aufrufe, Is.Empty);
|
|
});
|
|
}
|
|
|
|
/// <summary>Jede Quelle kommt mit zwei Werten: einem zum Vergleichen, einem zum Anzeigen.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Rotten Tomatoes in „8,7 von 10" umzurechnen wäre eine Lüge über
|
|
/// die Quelle.</b> Dort steht 87 %, und genau so kennt man es. Zum
|
|
/// Sortieren braucht es trotzdem eine gemeinsame Skala — deshalb beides.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Alle_drei_Quellen_kommen_normiert_und_lesbar()
|
|
{
|
|
var wertungen = await Client(Attrappe.MitJson(Antwort)).HolenAsync(Frage("tt0000001"));
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
var imdb = wertungen.Single(w => w.Quelle == "imdb");
|
|
Assert.That(imdb.Wert, Is.EqualTo(7.4).Within(0.01));
|
|
Assert.That(imdb.Anzeige, Is.EqualTo("7,4"));
|
|
|
|
var rt = wertungen.Single(w => w.Quelle == "rottenTomatoes");
|
|
Assert.That(rt.Wert, Is.EqualTo(8.7).Within(0.01));
|
|
Assert.That(rt.Anzeige, Is.EqualTo("87%"), "so steht es dort, und so kennt man es");
|
|
|
|
var mc = wertungen.Single(w => w.Quelle == "metacritic");
|
|
Assert.That(mc.Wert, Is.EqualTo(6.2).Within(0.01));
|
|
Assert.That(mc.Anzeige, Is.EqualTo("62"));
|
|
});
|
|
}
|
|
|
|
/// <summary>OMDb antwortet auf Unbekanntes mit 200 und „Response: False".</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Das als Treffer durchzulassen hiesse, eine leere Bewertung abzulegen
|
|
/// — und die stünde danach als „keine Wertung" da, obwohl es nur keine
|
|
/// Antwort gab.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Eine_abschlaegige_Antwort_ergibt_nichts()
|
|
{
|
|
var json = """{"Response":"False","Error":"Incorrect IMDb ID."}""";
|
|
|
|
Assert.That(await Client(Attrappe.MitJson(json)).HolenAsync(Frage("tt0000001")), Is.Empty);
|
|
}
|
|
|
|
/// <summary>Eine unbekannte vierte Quelle wird übergangen, nicht geraten.</summary>
|
|
[Test]
|
|
public async Task Eine_fremde_Quelle_wird_uebergangen()
|
|
{
|
|
var json = """
|
|
{"Response":"True","Ratings":[
|
|
{"Source":"Irgendwas Neues","Value":"5/5"},
|
|
{"Source":"Internet Movie Database","Value":"7.4/10"}]}
|
|
""";
|
|
|
|
var wertungen = await Client(Attrappe.MitJson(json)).HolenAsync(Frage("tt0000001"));
|
|
|
|
Assert.That(wertungen.Select(w => w.Quelle), Is.EquivalentTo(new[] { "imdb" }));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Kein_Netz_ergibt_nichts_statt_eines_Absturzes()
|
|
{
|
|
var wertungen = await Client(Attrappe.DieWirft(new HttpRequestException("weg")))
|
|
.HolenAsync(Frage("tt0000001"));
|
|
|
|
Assert.That(wertungen, Is.Empty);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Ein_Fehlercode_ergibt_nichts()
|
|
{
|
|
Assert.That(
|
|
await Client(Attrappe.MitStatus(HttpStatusCode.Unauthorized)).HolenAsync(Frage("tt0000001")),
|
|
Is.Empty);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Kennung_und_Schluessel_stehen_in_der_Anfrage()
|
|
{
|
|
var attrappe = Attrappe.MitJson(Antwort);
|
|
await Client(attrappe).HolenAsync(Frage("tt0133093"));
|
|
|
|
Assert.That(attrappe.Aufrufe[0],
|
|
Does.Contain("i=tt0133093").And.Contain("apikey=ein-schluessel"));
|
|
}
|
|
|
|
/// <summary>Der Publikumswert wird mitverlangt — er kostet nichts extra.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Eine Anfrage, nicht zwei.</b> OMDb legt die zusätzlichen Felder
|
|
/// derselben Antwort bei; das Tageskontingent von tausend Zugriffen
|
|
/// bleibt davon unberührt.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Der_Publikumswert_wird_mitverlangt()
|
|
{
|
|
var attrappe = Attrappe.MitJson(Antwort);
|
|
await Client(attrappe).HolenAsync(Frage("tt0133093"));
|
|
|
|
Assert.That(attrappe.Aufrufe, Has.Count.EqualTo(1));
|
|
Assert.That(attrappe.Aufrufe[0], Does.Contain("tomatoes=true"));
|
|
}
|
|
|
|
/// <summary>Steht eine Zahl im Publikumsfeld, wird sie eine eigene Wertung.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Getrennt von der Kritik, nicht vermischt.</b> Bei Rotten Tomatoes
|
|
/// liegen die beiden Zahlen oft weit auseinander; sie unter einem Namen
|
|
/// zu führen hiesse, dem Betrachter zu verschweigen, welche er sieht.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Ein_Publikumswert_kommt_getrennt_von_der_Kritik()
|
|
{
|
|
var json = """
|
|
{"Response":"True",
|
|
"Ratings":[{"Source":"Rotten Tomatoes","Value":"83%"}],
|
|
"tomatoUserMeter":"88"}
|
|
""";
|
|
|
|
var wertungen = await Client(Attrappe.MitJson(json)).HolenAsync(Frage("tt0000001"));
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
var kritik = wertungen.Single(w => w.Quelle == "rottenTomatoes");
|
|
Assert.That(kritik.Anzeige, Is.EqualTo("83%"));
|
|
|
|
var publikum = wertungen.Single(w => w.Quelle == "rottenTomatoesAudience");
|
|
Assert.That(publikum.Anzeige, Is.EqualTo("88%"));
|
|
Assert.That(publikum.Wert, Is.EqualTo(8.8).Within(0.01));
|
|
});
|
|
}
|
|
|
|
/// <summary><c>N/A</c> ist keine Wertung, sondern deren Fehlen.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Und das ist bei OMDb der Normalfall.</b> Seit 2018 wird berichtet,
|
|
/// dass die erweiterten Rotten-Tomatoes-Felder leer bleiben; die Frage,
|
|
/// ob sie je wieder Daten führen, ist seit 2017 offen. Eine „0 %"
|
|
/// daraus zu machen wäre eine Behauptung über einen Film.
|
|
/// </remarks>
|
|
[TestCase("\"N/A\"")]
|
|
[TestCase("\"\"")]
|
|
[TestCase("null")]
|
|
[TestCase("\"120\"")]
|
|
public async Task Ohne_brauchbaren_Publikumswert_entsteht_keine_Wertung(string feld)
|
|
{
|
|
var json = $$"""
|
|
{"Response":"True",
|
|
"Ratings":[{"Source":"Rotten Tomatoes","Value":"83%"}],
|
|
"tomatoUserMeter":{{feld}}}
|
|
""";
|
|
|
|
var wertungen = await Client(Attrappe.MitJson(json)).HolenAsync(Frage("tt0000001"));
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(wertungen.Select(w => w.Quelle), Is.EquivalentTo(new[] { "rottenTomatoes" }));
|
|
Assert.That(wertungen.Single().Anzeige, Is.EqualTo("83%"),
|
|
"die Kritik bleibt, auch wenn das Publikum fehlt");
|
|
});
|
|
}
|
|
|
|
/// <summary>Ein Zahlenfeld darf nicht die ganze Antwort verwerfen.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Fest auf Text getippt wäre das ein Lesefehler</b> — und der
|
|
/// verwirft nicht bloss dieses Feld, sondern die drei Bewertungen
|
|
/// daneben. OMDb schreibt hier bisher Text; darauf zu wetten ist
|
|
/// unnötig.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Ein_Publikumswert_als_Zahl_wird_auch_gelesen()
|
|
{
|
|
var json = """
|
|
{"Response":"True",
|
|
"Ratings":[{"Source":"Internet Movie Database","Value":"7.4/10"}],
|
|
"tomatoUserMeter":88}
|
|
""";
|
|
|
|
var wertungen = await Client(Attrappe.MitJson(json)).HolenAsync(Frage("tt0000001"));
|
|
|
|
Assert.That(wertungen.Select(w => w.Quelle),
|
|
Is.EquivalentTo(new[] { "imdb", "rottenTomatoesAudience" }));
|
|
}
|
|
|
|
/// <summary>Ohne IMDb-Kennung wird gar nicht erst gefragt.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// OMDb kennt nur diesen Weg; nach einem Titel zu suchen hiesse, denselben
|
|
/// Zuordnungsfehler ein zweites Mal zu riskieren.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Ohne_Kennung_geht_keine_Anfrage_hinaus()
|
|
{
|
|
var attrappe = Attrappe.MitJson(Antwort);
|
|
|
|
await Client(attrappe).HolenAsync(Frage(""));
|
|
|
|
Assert.That(attrappe.Aufrufe, Is.Empty);
|
|
}
|
|
}
|
|
|
|
public sealed record FesterOmdbSchluessel(string? Wert) : IOmdbSettingsSource
|
|
{
|
|
public Task<string?> SchluesselAsync(CancellationToken ct = default) => Task.FromResult(Wert);
|
|
}
|