From ac64b20a5bdd6959ddf6fc8673eaf99b62036947 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 8 Oct 2026 22:41:41 +0200 Subject: [PATCH] =?UTF-8?q?Jede=20Zahl=20sagt=20jetzt,=20was=20sie=20ist?= =?UTF-8?q?=20=E2=80=94=20und=20woher=20sie=20kommt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nebeneinander standen „7,2" und „12", beide ohne Beschriftung. 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. Jetzt trägt jeder Wert seine Quelle: FSK 12, TMDB 7,2, IMDb 7,4, MC 62. Bei Rotten Tomatoes ist „RT 87%" ebenfalls keine Auskunft, denn dort stehen zwei Zahlen — die der Kritik und die des Publikums, oft weit auseinander. Also „RT Kritik" und „RT Publikum", auch wenn nur eine der beiden da ist. Und das Publikum wird mitverlangt: `tomatoes=true` legt die Felder derselben Antwort bei, kostet keine zweite Anfrage und nichts vom Tageskontingent. Ob etwas darin steht, ist eine andere Frage — seit 2018 wird berichtet, dass dort `N/A` steht, und die Frage, ob die Felder je wieder Daten führen, ist bei OMDb seit 2017 unbeantwortet offen. Darum wird nichts erwartet und nichts erzwungen: steht eine Zahl da, erscheint sie; steht `N/A`, entsteht keine Wertung. Die Probe unter Einstellungen → Metadaten zeigt, was wirklich kam. Co-Authored-By: Claude Opus 5 --- server/src/Flaro.Api/Quellenprobe.cs | 3 +- server/src/Flaro.Metadata/OmdbClient.cs | 57 +++++++++- .../tests/Flaro.Metadata.Tests/OmdbTests.cs | 99 +++++++++++++++++ web/app/src/views/detail.ts | 100 +++++++++++++----- 4 files changed, 229 insertions(+), 30 deletions(-) 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; }