diff --git a/server/src/Flaro.Api/Quellenprobe.cs b/server/src/Flaro.Api/Quellenprobe.cs
index d6c61ef..66c9526 100644
--- a/server/src/Flaro.Api/Quellenprobe.cs
+++ b/server/src/Flaro.Api/Quellenprobe.cs
@@ -105,7 +105,8 @@ public sealed class Quellenprobe(
private static string Kurz(string quelle) => quelle switch
{
"imdb" => "IMDb",
- "rottenTomatoes" => "RT",
+ "rottenTomatoes" => "RT Kritik",
+ "rottenTomatoesAudience" => "RT Publikum",
"metacritic" => "MC",
_ => quelle,
};
diff --git a/server/src/Flaro.Metadata/OmdbClient.cs b/server/src/Flaro.Metadata/OmdbClient.cs
index d400c88..b7535d3 100644
--- a/server/src/Flaro.Metadata/OmdbClient.cs
+++ b/server/src/Flaro.Metadata/OmdbClient.cs
@@ -28,6 +28,17 @@ public interface IOmdbSettingsSource
/// und genau das kennt man von dort.
///
///
+/// Der Publikumswert von Rotten Tomatoes kommt mit, falls OMDb ihn
+/// hat. Mit tomatoes=true legt OMDb weitere Felder derselben
+/// Antwort bei — keine zweite Anfrage, kein zweiter Abzug vom
+/// Tageskontingent. Darin steht tomatoUserMeter: der Wert des
+/// Publikums neben dem der Kritik. Seit 2018 wird berichtet, dass dort
+/// meist N/A steht (omdbapi/OMDb-API#102), und die Frage, ob die
+/// Felder je wieder Daten führen, ist seit 2017 unbeantwortet offen (#5).
+/// Deshalb wird nichts erwartet und nichts erzwungen: steht eine Zahl da,
+/// erscheint sie; steht N/A, entsteht keine Wertung.
+///
+///
/// Ohne Schlüssel geschieht nichts. OMDb verlangt einen
/// persönlichen, kostenlosen; einen fremden mitzuliefern hiesse, dessen
/// Tageskontingent zu verbrauchen.
@@ -53,7 +64,8 @@ public sealed class OmdbClient(
try
{
using var ergebnis = await http.GetAsync(
- $"?i={Uri.EscapeDataString(imdbId)}&apikey={Uri.EscapeDataString(schluessel)}", ct);
+ $"?i={Uri.EscapeDataString(imdbId)}&tomatoes=true"
+ + $"&apikey={Uri.EscapeDataString(schluessel)}", ct);
if (!ergebnis.IsSuccessStatusCode)
{
@@ -85,9 +97,49 @@ public sealed class OmdbClient(
liste.Add(new Fremdwertung(quelle, wert, Anzeige(quelle, r.Value)));
}
+ // **Der Publikumswert steht nicht in `Ratings`, sondern einzeln.**
+ // Die Liste führt nur je eine Zahl pro Dienst, und das ist bei
+ // Rotten Tomatoes die der Kritik.
+ if (Prozent(Rohwert(antwort.TomatoUserMeter)) is { } publikum)
+ {
+ liste.Add(new Fremdwertung(
+ "rottenTomatoesAudience",
+ publikum / 10,
+ publikum.ToString("0", CultureInfo.InvariantCulture) + "%"));
+ }
+
return liste;
}
+ /// Holt den Text aus einem Feld, das auch eine Zahl sein kann.
+ ///
+ ///
+ /// Fest auf string getippt wäre ein Zahlenfeld ein
+ /// Lesefehler — und der verwirft nicht nur dieses Feld, sondern die
+ /// ganze Antwort samt der drei Bewertungen, die darin stehen. OMDb
+ /// schreibt hier bisher Text; darauf zu wetten ist unnötig.
+ ///
+ private static string? Rohwert(JsonElement? feld) => feld switch
+ {
+ { ValueKind: JsonValueKind.String } e => e.GetString(),
+ { ValueKind: JsonValueKind.Number } e => e.GetRawText(),
+ _ => null,
+ };
+
+ /// Liest „88" oder „88%" als Zahl — und N/A als nichts.
+ private static double? Prozent(string? roh)
+ {
+ if (string.IsNullOrWhiteSpace(roh)) return null;
+
+ var zahl = roh.Trim().TrimEnd('%');
+ if (!double.TryParse(zahl, NumberStyles.Float, CultureInfo.InvariantCulture, out var wert))
+ return null;
+
+ // Ausserhalb von 0 bis 100 ist es kein Prozentwert, sondern ein
+ // Missverständnis über das Feld.
+ return wert is >= 0 and <= 100 ? wert : null;
+ }
+
/// Die Namen, die OMDb benutzt, auf unsere Kennungen.
private static string? Quelle(string? name) => name switch
{
@@ -129,7 +181,8 @@ public sealed class OmdbClient(
private sealed record OmdbAntwort(
[property: JsonPropertyName("Response")] string? Response,
- [property: JsonPropertyName("Ratings")] List? Ratings);
+ [property: JsonPropertyName("Ratings")] List? Ratings,
+ [property: JsonPropertyName("tomatoUserMeter")] JsonElement? TomatoUserMeter = null);
private sealed record OmdbWertung(
[property: JsonPropertyName("Source")] string? Source,
diff --git a/server/tests/Flaro.Metadata.Tests/OmdbTests.cs b/server/tests/Flaro.Metadata.Tests/OmdbTests.cs
index d7335d1..2ec9e6d 100644
--- a/server/tests/Flaro.Metadata.Tests/OmdbTests.cs
+++ b/server/tests/Flaro.Metadata.Tests/OmdbTests.cs
@@ -126,6 +126,105 @@ public class OmdbTests
Does.Contain("i=tt0133093").And.Contain("apikey=ein-schluessel"));
}
+ /// Der Publikumswert wird mitverlangt — er kostet nichts extra.
+ ///
+ ///
+ /// Eine Anfrage, nicht zwei. OMDb legt die zusätzlichen Felder
+ /// derselben Antwort bei; das Tageskontingent von tausend Zugriffen
+ /// bleibt davon unberührt.
+ ///
+ [Test]
+ public async Task Der_Publikumswert_wird_mitverlangt()
+ {
+ var attrappe = Attrappe.MitJson(Antwort);
+ await Client(attrappe).HolenAsync("tt0133093");
+
+ Assert.That(attrappe.Aufrufe, Has.Count.EqualTo(1));
+ Assert.That(attrappe.Aufrufe[0], Does.Contain("tomatoes=true"));
+ }
+
+ /// Steht eine Zahl im Publikumsfeld, wird sie eine eigene Wertung.
+ ///
+ ///
+ /// Getrennt von der Kritik, nicht vermischt. 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.
+ ///
+ [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("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));
+ });
+ }
+
+ /// N/A ist keine Wertung, sondern deren Fehlen.
+ ///
+ ///
+ /// Und das ist bei OMDb der Normalfall. 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.
+ ///
+ [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("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");
+ });
+ }
+
+ /// Ein Zahlenfeld darf nicht die ganze Antwort verwerfen.
+ ///
+ ///
+ /// Fest auf Text getippt wäre das ein Lesefehler — und der
+ /// verwirft nicht bloss dieses Feld, sondern die drei Bewertungen
+ /// daneben. OMDb schreibt hier bisher Text; darauf zu wetten ist
+ /// unnötig.
+ ///
+ [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("tt0000001");
+
+ Assert.That(wertungen.Select(w => w.Quelle),
+ Is.EquivalentTo(new[] { "imdb", "rottenTomatoesAudience" }));
+ }
+
/// Ohne IMDb-Kennung wird gar nicht erst gefragt.
///
///
diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts
index ac55153..a75420a 100644
--- a/web/app/src/views/detail.ts
+++ b/web/app/src/views/detail.ts
@@ -26,15 +26,41 @@ type Art = 'filme' | 'serien';
*
* **Kein Logo.** Die Marken von IMDb und Rotten Tomatoes mitzuliefern
* hiesse, fremde Zeichen auszuliefern; ein Kürzel sagt dasselbe und gehört
- * uns. */
+ * uns.
+ *
+ * **Rotten Tomatoes trägt zwei Zahlen, und „RT 87%" sagt nicht, welche.**
+ * Dort steht der Wert der Kritik neben dem des Publikums; beide heissen
+ * gleich und sind oft weit auseinander. Deshalb steht die Hälfte dabei —
+ * auch wenn nur eine der beiden da ist. */
const QUELLENKUERZEL: Record = {
- imdb: 'IMDb', rottenTomatoes: 'RT', metacritic: 'MC',
+ imdb: 'IMDb',
+ rottenTomatoes: 'RT Kritik',
+ rottenTomatoesAudience: 'RT Publikum',
+ metacritic: 'MC',
};
-const QUELLENNAMEN: Record = {
- imdb: 'IMDb', rottenTomatoes: 'Rotten Tomatoes', metacritic: 'Metacritic',
+/** Was im Tooltip steht — ein ganzer Satz, nicht nur der Name. */
+const QUELLENERKLAERUNG: Record = {
+ imdb: 'Bewertung bei IMDb',
+ rottenTomatoes: 'Rotten Tomatoes: die Wertung der Kritik (Tomatometer)',
+ rottenTomatoesAudience: 'Rotten Tomatoes: die Wertung des Publikums',
+ metacritic: 'Bewertung bei Metacritic',
};
+/** In welcher Reihenfolge sie stehen, unabhängig davon, wie sie ankamen.
+ *
+ * **Die beiden Werte von Rotten Tomatoes gehören nebeneinander.** Der
+ * Server hängt den Publikumswert hinten an, weil er in der Antwort an
+ * anderer Stelle steht als die übrigen; zwischen ihnen stünde dann
+ * Metacritic. */
+const QUELLENFOLGE = ['imdb', 'rottenTomatoes', 'rottenTomatoesAudience', 'metacritic'];
+
+/** Eine Quelle, die es noch nicht gab, steht hinten statt vorn. */
+function rang(quelle: string): number {
+ const stelle = QUELLENFOLGE.indexOf(quelle);
+ return stelle < 0 ? QUELLENFOLGE.length : stelle;
+}
+
export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElement {
const wurzel = document.createElement('div');
wurzel.className = 'detail';
@@ -215,6 +241,27 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
return kopf;
}
+ /** Eine Wertung mit ihrer Quelle davor.
+ *
+ * **Der Name steht im Bild, nicht im Tooltip.** Eine Zahl ohne Quelle ist
+ * keine Auskunft — und ein Tooltip sieht nur, wer schon vermutet, dass
+ * dort etwas steht. Der Stern bleibt der Wertung von TMDB vorbehalten;
+ * ihn für vier Quellen zu wiederholen hiesse, vier gleich aussehende
+ * Zahlen nebeneinanderzustellen. */
+ function wertung(quelle: string, wert: string, titel: string, stern = false): HTMLElement {
+ const b = document.createElement('span');
+ b.className = 'bewertung' + (stern ? '' : ' fremd');
+
+ const name = document.createElement('span');
+ name.className = 'bewertungsquelle';
+ name.textContent = quelle;
+
+ if (stern) b.innerHTML = icons.stern;
+ b.append(name, document.createTextNode(wert));
+ b.title = titel;
+ return b;
+ }
+
/** Jahr · Genres · Laufzeit · Freigabe · Bewertung — eine Zeile. */
function eckdaten(eintrag: Film | Serie): HTMLElement {
const zeile = document.createElement('div');
@@ -234,39 +281,38 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
zeile.appendChild(t);
}
+ // **Jede Zahl sagt, was sie ist.** Hier standen „7,2" und „12"
+ // nebeneinander, beide ohne Beschriftung — und die 12 konnte alles
+ // heissen: zwölf Folgen, zwölf von hundert Punkten, ab zwölf Jahren.
+ // Die Auskunft steckte im Tooltip, und den sieht nur, wer schon
+ // vermutet, dass dort etwas steht.
if (eintrag.contentRating) {
const f = document.createElement('span');
f.className = 'freigabe';
- f.textContent = eintrag.contentRating;
+ // Eine reine Zahl ist eine Altersangabe und heisst hierzulande FSK.
+ // „PG-13" steht für sich — „FSK PG-13" wäre falsch.
+ f.textContent = /^\d+$/.test(eintrag.contentRating)
+ ? `FSK ${eintrag.contentRating}`
+ : eintrag.contentRating;
f.title = 'Altersfreigabe';
zeile.appendChild(f);
}
if (eintrag.rating) {
- const b = document.createElement('span');
- b.className = 'bewertung';
- b.innerHTML = icons.stern;
- b.append(document.createTextNode(eintrag.rating.toFixed(1)));
- b.title = 'Bewertung bei TMDB';
- zeile.appendChild(b);
+ zeile.appendChild(wertung('TMDB', eintrag.rating.toFixed(1), 'Bewertung bei TMDB', true));
}
- // **Fremde Bewertungen daneben, nicht statt dessen.**
- //
- // Jede Quelle in ihrer eigenen Form: IMDb zählt bis zehn, Rotten
- // Tomatoes in Prozent, Metacritic bis hundert. „87 %" in „8,7 von 10"
- // umzurechnen wäre eine Lüge über die Quelle — man kennt sie so, wie
- // sie dort steht.
- (eintrag.externalRatings ?? []).forEach(w => {
- const b = document.createElement('span');
- b.className = 'bewertung fremd';
- const name = document.createElement('span');
- name.className = 'bewertungsquelle';
- name.textContent = QUELLENKUERZEL[w.source] ?? w.source;
- b.append(name, document.createTextNode(w.display));
- b.title = `Bewertung bei ${QUELLENNAMEN[w.source] ?? w.source}`;
- zeile.appendChild(b);
- });
+ // Die fremden Wertungen daneben, jede in ihrer eigenen Form: IMDb zählt
+ // bis zehn, Rotten Tomatoes in Prozent, Metacritic bis hundert. „87 %"
+ // in „8,7 von 10" umzurechnen wäre eine Lüge über die Quelle.
+ [...(eintrag.externalRatings ?? [])]
+ .sort((a, b) => rang(a.source) - rang(b.source))
+ .forEach(w => {
+ zeile.appendChild(wertung(
+ QUELLENKUERZEL[w.source] ?? w.source,
+ w.display,
+ QUELLENERKLAERUNG[w.source] ?? `Bewertung bei ${w.source}`));
+ });
return zeile;
}