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