diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index b045c10..661aeba 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -571,7 +571,15 @@ public sealed record Saisonreihe(string? Occasion, Page Items); /// public sealed record MetadataSettingsResponse( bool HasApiKey, string Language, bool UsingProjectKey, - bool HasFanartKey = false, bool HasOmdbKey = false); + bool HasFanartKey = false, bool HasOmdbKey = false, + + /// Ob die Seite von Rotten Tomatoes gelesen werden darf. + /// + /// + /// Kein Schlüssel, sondern ein Schalter. Dort gibt es keine + /// Zugänge zu vergeben; eingeschaltet wird das Auslesen der Seite. + /// + bool RottenTomatoes = false); /// Was ein Client über der Mediathek anzeigen muss. /// @@ -584,7 +592,8 @@ public sealed record MetadataSettingsResponse( public sealed record AttributionResponse(string Text, string LogoUrl, string Url); public sealed record MetadataSettingsRequest( - string? ApiKey, string? Language, string? FanartApiKey = null, string? OmdbApiKey = null); + string? ApiKey, string? Language, string? FanartApiKey = null, string? OmdbApiKey = null, + bool? RottenTomatoes = null); /// Was der Server über die Wiedergabe entscheidet. /// diff --git a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs index 8d12619..9a19462 100644 --- a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs @@ -19,7 +19,8 @@ public static class SettingsEndpoints .Where(s => s.Key == DatabaseTmdbSettings.ApiKeyName || s.Key == DatabaseTmdbSettings.LanguageName || s.Key == DatabaseFanartSettings.ApiKeyName - || s.Key == DatabaseOmdbSettings.ApiKeyName) + || s.Key == DatabaseOmdbSettings.ApiKeyName + || s.Key == DatabaseRottenTomatoesSettings.SchalterName) .ToDictionaryAsync(s => s.Key, s => s.Value); // **Der Schlüssel selbst kommt nicht zurück.** Er gäbe jedem @@ -38,7 +39,10 @@ public static class SettingsEndpoints HasFanartKey: !string.IsNullOrWhiteSpace( zeilen.GetValueOrDefault(DatabaseFanartSettings.ApiKeyName)), HasOmdbKey: !string.IsNullOrWhiteSpace( - zeilen.GetValueOrDefault(DatabaseOmdbSettings.ApiKeyName)))); + zeilen.GetValueOrDefault(DatabaseOmdbSettings.ApiKeyName)), + RottenTomatoes: string.Equals( + zeilen.GetValueOrDefault(DatabaseRottenTomatoesSettings.SchalterName), + "true", StringComparison.OrdinalIgnoreCase))); }).RequireAuthorization(FlaroPolicies.Administrator); // **In welcher Sprache der Bestand vorliegt.** @@ -100,6 +104,11 @@ public static class SettingsEndpoints await Setzen(db, DatabaseFanartSettings.ApiKeyName, anfrage.FanartApiKey.Trim()); if (anfrage.OmdbApiKey is not null) await Setzen(db, DatabaseOmdbSettings.ApiKeyName, anfrage.OmdbApiKey.Trim()); + + // **Ein Schalter, kein Schlüssel.** Rotten Tomatoes vergibt keine + // Zugänge; eingeschaltet wird hier das Lesen ihrer Seite. + if (anfrage.RottenTomatoes is { } an) + await Setzen(db, DatabaseRottenTomatoesSettings.SchalterName, an ? "true" : "false"); if (anfrage.Language is not null) { var sprache = anfrage.Language.Trim(); diff --git a/server/src/Flaro.Api/MetadataSettings.cs b/server/src/Flaro.Api/MetadataSettings.cs index 9f4d917..2bde7bf 100644 --- a/server/src/Flaro.Api/MetadataSettings.cs +++ b/server/src/Flaro.Api/MetadataSettings.cs @@ -88,3 +88,31 @@ public sealed class DatabaseOmdbSettings(FlaroContext db) : IOmdbSettingsSource return _gemerkt = string.IsNullOrWhiteSpace(zeile?.Value) ? null : zeile.Value.Trim(); } } + +/// Ob die Seite von Rotten Tomatoes gelesen werden darf. +/// +/// +/// Kein Schlüssel, sondern ein Schalter — und der steht auf aus. +/// Rotten Tomatoes vergibt keine Zugänge; die beiden Werte stehen nur auf +/// der Seite selbst. Das ist Auslesen einer fremden Webseite: es bricht, +/// wenn dort umgebaut wird, und es geschieht ohne Einverständnis des +/// Betreibers. Eine solche Quelle gehört nicht stillschweigend +/// eingeschaltet, sondern bewusst. +/// +public sealed class DatabaseRottenTomatoesSettings(FlaroContext db) : IRottenTomatoesSettingsSource +{ + public const string SchalterName = "metadata.rottenTomatoes.enabled"; + + private bool? _gemerkt; + + public async Task EingeschaltetAsync(CancellationToken ct = default) + { + if (_gemerkt is { } schon) return schon; + + var zeile = await db.Settings.AsNoTracking() + .FirstOrDefaultAsync(s => s.Key == SchalterName, ct); + + return (_gemerkt = string.Equals(zeile?.Value, "true", StringComparison.OrdinalIgnoreCase)) + .Value; + } +} diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index 2ff68e2..97c5b61 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -123,7 +123,36 @@ builder.Services.AddHttpClient(c => c.BaseAddress = new Uri(OmdbClient.BaseAddress); c.Timeout = TimeSpan.FromSeconds(20); }); -builder.Services.AddScoped(sp => sp.GetRequiredService()); + +// **Rotten Tomatoes dazu, aber nur auf Wunsch.** Dort gibt es keine +// Schnittstelle — die beiden Werte stehen auf der Seite selbst, und OMDb +// gibt davon nur den der Kritik weiter. Das Auslesen einer fremden Webseite +// bricht, wenn dort umgebaut wird; deshalb ein Schalter, und der steht aus. +builder.Services.AddScoped(); + +// **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. +builder.Services.AddSingleton(new Anklopfbremse(TimeSpan.FromSeconds(1))); +builder.Services.AddHttpClient(c => +{ + c.BaseAddress = new Uri(RottenTomatoesClient.BaseAddress); + c.Timeout = TimeSpan.FromSeconds(20); + // Ohne Browserkennung antwortet die Seite mit einer Sperrseite statt + // mit dem Film. + c.DefaultRequestHeaders.UserAgent.ParseAdd( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " + + "Chrome/131.0.0.0 Safari/537.36"); +}); + +// **Die Reihenfolge ist die Rangfolge.** Beide führen den Wert der Kritik; +// OMDb aus zweiter Hand, Rotten Tomatoes als Quelle selbst. Wer hinten +// steht, überschreibt. +builder.Services.AddScoped(sp => new Bewertungsquellen( +[ + sp.GetRequiredService(), + sp.GetRequiredService(), +])); // **Damit man sieht, ob ein Schlüssel wirkt.** „Hinterlegt" ist keine // Auskunft darüber, ob der Dienst antwortet. diff --git a/server/src/Flaro.Api/Quellenprobe.cs b/server/src/Flaro.Api/Quellenprobe.cs index 66c9526..57dcdbe 100644 --- a/server/src/Flaro.Api/Quellenprobe.cs +++ b/server/src/Flaro.Api/Quellenprobe.cs @@ -32,17 +32,21 @@ public sealed class Quellenprobe( TmdbClient tmdb, FanartClient fanart, OmdbClient omdb, + RottenTomatoesClient tomaten, ITmdbSettingsSource spracheVon) { - /// „The Matrix" — bei allen drei Quellen vorhanden. + /// „The Matrix" — bei allen Quellen vorhanden. private const int ProbeTmdbId = 603; private const string ProbeImdbId = "tt0133093"; + private const string ProbeTitel = "The Matrix"; + private const int ProbeJahr = 1999; public async Task> LaufenAsync(CancellationToken ct = default) => [ await TmdbProbe(ct), await FanartProbe(ct), await OmdbProbe(ct), + await TomatenProbe(ct), ]; private async Task TmdbProbe(CancellationToken ct) @@ -89,7 +93,8 @@ public sealed class Quellenprobe( if (!await omdb.EingerichtetAsync(ct)) return new("omdb", false, false, "Kein Schlüssel hinterlegt — die Quelle ist aus."); - var wertungen = await omdb.HolenAsync(ProbeImdbId, ct); + var wertungen = await omdb.HolenAsync( + new Wertungsfrage(ProbeImdbId, ProbeTitel, ProbeJahr), ct); if (wertungen.Count == 0) { @@ -98,10 +103,40 @@ public sealed class Quellenprobe( + "oder ist das Tageskontingent aufgebraucht?"); } - var namen = string.Join(", ", wertungen.Select(w => $"{Kurz(w.Quelle)} {w.Anzeige}")); - return new("omdb", true, true, $"Erreichbar — zu The Matrix: {namen}."); + return new("omdb", true, true, $"Erreichbar — zu The Matrix: {Aufzaehlung(wertungen)}."); } + /// Die Seite von Rotten Tomatoes, falls das Lesen erlaubt wurde. + /// + /// + /// Hier ist die Probe mehr als eine Höflichkeit. Bei den anderen + /// beantwortet sie die Frage, ob ein Schlüssel stimmt; hier beantwortet + /// sie die Frage, ob das Auslesen überhaupt noch geht. Wird die Seite + /// umgebaut oder der Zugriff gesperrt, merkt man es genau hier — und + /// sonst nirgends. + /// + private async Task TomatenProbe(CancellationToken ct) + { + if (!await tomaten.EingerichtetAsync(ct)) + return new("rottenTomatoes", false, false, "Ausgeschaltet."); + + var wertungen = await tomaten.HolenAsync( + new Wertungsfrage(ProbeImdbId, ProbeTitel, ProbeJahr), ct); + + if (wertungen.Count == 0) + { + return new("rottenTomatoes", true, false, + "Nichts bekommen. Entweder ist der Zugriff gesperrt, " + + "oder die Seite ist umgebaut worden."); + } + + return new("rottenTomatoes", true, true, + $"Erreichbar — zu The Matrix: {Aufzaehlung(wertungen)}."); + } + + private static string Aufzaehlung(IEnumerable wertungen) => + string.Join(", ", wertungen.Select(w => $"{Kurz(w.Quelle)} {w.Anzeige}")); + private static string Kurz(string quelle) => quelle switch { "imdb" => "IMDb", diff --git a/server/src/Flaro.Core/Metadata/IMetadataProvider.cs b/server/src/Flaro.Core/Metadata/IMetadataProvider.cs index e0855f4..33c6cd4 100644 --- a/server/src/Flaro.Core/Metadata/IMetadataProvider.cs +++ b/server/src/Flaro.Core/Metadata/IMetadataProvider.cs @@ -318,8 +318,43 @@ public interface IBewertungsquelle { Task EingerichtetAsync(CancellationToken ct = default); - /// Alle Bewertungen zu einer IMDb-Kennung. - Task> HolenAsync(string imdbId, CancellationToken ct = default); + /// Alle Bewertungen zu einem Titel. + Task> HolenAsync(Wertungsfrage frage, CancellationToken ct = default); +} + +/// Wonach eine Bewertungsquelle gefragt wird. +/// +/// Die IMDb-Kennung, falls bekannt. +/// Der Titel, wie die Metadatenquelle ihn führt. +/// Das Erscheinungsjahr, falls bekannt. +/// Ob es eine Serie ist und kein Film. +/// +/// Der Titel in der Sprache des Originals, falls er abweicht. +/// +/// +/// Ohne ihn fände ein deutscher Bestand bei Rotten Tomatoes fast +/// nichts. Steht die Mediathek auf Deutsch, heisst der Film hier „Ich +/// einfach unverbesserlich"; dort gibt es nur despicable_me. +/// +/// +/// +/// +/// Nicht jede Quelle kennt denselben Weg hinein. OMDb nimmt +/// ausschliesslich die IMDb-Kennung; Rotten Tomatoes hat keine offene Suche +/// und muss über Titel und Jahr gehen. Hier stand vorher eine blanke +/// IMDb-Kennung als einziger Parameter — damit liess sich die zweite Quelle +/// gar nicht anschliessen. +/// +public sealed record Wertungsfrage( + string? ImdbId, string Titel, int? Jahr, bool Serie = false, string? Originaltitel = null) +{ + /// Beide Titel, der originale zuerst — ohne Leeres und ohne Dubletten. + public IReadOnlyList Titelvarianten => + new[] { Originaltitel, Titel } + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t!.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); } /// Wie Bilder nach Sprache geordnet werden. diff --git a/server/src/Flaro.Data/MetadataEnricher.cs b/server/src/Flaro.Data/MetadataEnricher.cs index d91f165..b494c69 100644 --- a/server/src/Flaro.Data/MetadataEnricher.cs +++ b/server/src/Flaro.Data/MetadataEnricher.cs @@ -249,7 +249,9 @@ public sealed class MetadataEnricher( film.ContentRating = gefunden.ContentRating; film.MatchLocked = festgelegt; - await BewertungenSetzen(film.Id, null, gefunden.ImdbId, ct); + await BewertungenSetzen(film.Id, null, + new Wertungsfrage(gefunden.ImdbId, gefunden.Title, gefunden.Year, + Originaltitel: gefunden.OriginalTitle), ct); await GenresSetzen(gefunden.Genres, film: film.Id, serie: null, ct); await StichworteSetzen(gefunden.Keywords, film: film.Id, serie: null, ct); await MitwirkendeSetzen(gefunden.Credits, film: film.Id, serie: null, ct); @@ -408,7 +410,9 @@ public sealed class MetadataEnricher( serie.Tagline = gefunden.Tagline; serie.ContentRating = gefunden.ContentRating; - await BewertungenSetzen(null, serie.Id, gefunden.ImdbId, ct); + await BewertungenSetzen(null, serie.Id, + new Wertungsfrage(gefunden.ImdbId, gefunden.Title, gefunden.Year, + Serie: true, Originaltitel: gefunden.OriginalTitle), ct); await GenresSetzen(gefunden.Genres, film: null, serie: serie.Id, ct); await StichworteSetzen(gefunden.Keywords, film: null, serie: serie.Id, ct); await MitwirkendeSetzen(gefunden.Credits, film: null, serie: serie.Id, ct); @@ -784,17 +788,18 @@ public sealed class MetadataEnricher( /// eindeutige Index auf (Titel, Quelle) hielte das ohnehin ab — besser, /// er muss es nie. /// - /// Ohne IMDb-Kennung geht gar nichts. OMDb kennt nur diesen Weg; - /// einen Titel zu suchen hiesse, denselben Zuordnungsfehler ein zweites - /// Mal zu riskieren. + /// Jede Quelle nimmt einen anderen Weg hinein. OMDb kennt nur die + /// IMDb-Kennung und antwortet ohne sie gar nicht; Rotten Tomatoes hat + /// keine offene Suche und muss über Titel und Jahr gehen. Deshalb wird + /// hier alles mitgegeben, was bekannt ist, und jede Quelle nimmt sich, + /// was sie braucht. /// private async Task BewertungenSetzen( - Guid? film, Guid? serie, string? imdbId, CancellationToken ct) + Guid? film, Guid? serie, Wertungsfrage frage, CancellationToken ct) { - if (string.IsNullOrWhiteSpace(imdbId)) return; if (!await bewertungen.EingerichtetAsync(ct)) return; - var gefunden = await bewertungen.HolenAsync(imdbId, ct); + var gefunden = await bewertungen.HolenAsync(frage, ct); if (gefunden.Count == 0) return; var vorhanden = await db.Fremdbewertungen diff --git a/server/src/Flaro.Metadata/Anklopfbremse.cs b/server/src/Flaro.Metadata/Anklopfbremse.cs new file mode 100644 index 0000000..3fd30dc --- /dev/null +++ b/server/src/Flaro.Metadata/Anklopfbremse.cs @@ -0,0 +1,50 @@ +using System.Diagnostics; + +namespace Flaro.Metadata; + +/// Lässt höchstens eine Anfrage je Zeitspanne durch. +/// +/// +/// +/// Für eine Seite, die man mitbenutzt. Ein Scan über achthundert +/// Titel wäre sonst ein Schwall von achthundert Anfragen in wenigen +/// Sekunden — von einer einzigen Adresse, an einen Dienst, der uns nichts +/// schuldet. Das wird gesperrt, und zwar zu Recht. +/// +/// +/// Eine Warteschlange, kein Verwerfen. Wer zu früh kommt, wartet; +/// niemand verliert seine Anfrage. Ein Scan wird dadurch langsamer — das +/// ist der Preis, und er ist gewollt. +/// +/// +/// und nicht die Uhrzeit. Eine Zeitumstellung +/// oder ein Abgleich mit dem Zeitserver liesse die Bremse sonst entweder +/// eine Stunde sperren oder gar nicht greifen. +/// +/// +public sealed class Anklopfbremse(TimeSpan pause) +{ + private readonly SemaphoreSlim _tor = new(1, 1); + private long _zuletzt = long.MinValue; + + public async Task WartenAsync(CancellationToken ct = default) + { + if (pause <= TimeSpan.Zero) return; + + await _tor.WaitAsync(ct); + try + { + if (_zuletzt != long.MinValue) + { + var seither = Stopwatch.GetElapsedTime(_zuletzt); + if (seither < pause) await Task.Delay(pause - seither, ct); + } + + _zuletzt = Stopwatch.GetTimestamp(); + } + finally + { + _tor.Release(); + } + } +} diff --git a/server/src/Flaro.Metadata/Bewertungsquellen.cs b/server/src/Flaro.Metadata/Bewertungsquellen.cs new file mode 100644 index 0000000..4248904 --- /dev/null +++ b/server/src/Flaro.Metadata/Bewertungsquellen.cs @@ -0,0 +1,56 @@ +using Flaro.Core.Metadata; + +namespace Flaro.Metadata; + +/// Mehrere Bewertungsquellen als eine. +/// +/// +/// In der Reihenfolge, in der sie gefragt werden. Die spätere gewinnt, +/// wenn beide dieselbe Zahl führen. +/// +/// +/// +/// +/// Warum nicht einfach zwei Felder im Einleser. Der kennt dann beide +/// Quellen beim Namen, und jede dritte verlangt wieder eine Änderung an ihm. +/// So fragt er eine und bekommt alles. +/// +/// +/// Die spätere gewinnt, und das ist kein Zufall. OMDb und Rotten +/// Tomatoes führen beide den Wert der Kritik; OMDb gibt ihn aus zweiter +/// Hand weiter, Rotten Tomatoes ist die Quelle selbst. Steht Rotten Tomatoes +/// hinten, überschreibt die erste Hand die zweite. +/// +/// +/// Eine Quelle, die nichts liefert, nimmt nichts weg. Eine leere +/// Antwort ersetzt keine vorhandene Zahl — sonst löschte ein Aussetzer bei +/// Rotten Tomatoes die Bewertungen, die OMDb schon geliefert hat. +/// +/// +public sealed class Bewertungsquellen(IReadOnlyList quellen) : IBewertungsquelle +{ + public async Task EingerichtetAsync(CancellationToken ct = default) + { + foreach (var q in quellen) + { + if (await q.EingerichtetAsync(ct)) return true; + } + + return false; + } + + public async Task> HolenAsync( + Wertungsfrage frage, CancellationToken ct = default) + { + var nachQuelle = new Dictionary(StringComparer.Ordinal); + + foreach (var q in quellen) + { + if (!await q.EingerichtetAsync(ct)) continue; + + foreach (var w in await q.HolenAsync(frage, ct)) nachQuelle[w.Quelle] = w; + } + + return [.. nachQuelle.Values]; + } +} diff --git a/server/src/Flaro.Metadata/OmdbClient.cs b/server/src/Flaro.Metadata/OmdbClient.cs index b7535d3..15f2beb 100644 --- a/server/src/Flaro.Metadata/OmdbClient.cs +++ b/server/src/Flaro.Metadata/OmdbClient.cs @@ -55,8 +55,9 @@ public sealed class OmdbClient( !string.IsNullOrWhiteSpace(await einstellungen.SchluesselAsync(ct)); public async Task> HolenAsync( - string imdbId, CancellationToken ct = default) + Wertungsfrage frage, CancellationToken ct = default) { + var imdbId = frage.ImdbId ?? ""; var schluessel = await einstellungen.SchluesselAsync(ct); if (string.IsNullOrWhiteSpace(schluessel) || string.IsNullOrWhiteSpace(imdbId)) return []; diff --git a/server/src/Flaro.Metadata/RottenTomatoesClient.cs b/server/src/Flaro.Metadata/RottenTomatoesClient.cs new file mode 100644 index 0000000..5800efa --- /dev/null +++ b/server/src/Flaro.Metadata/RottenTomatoesClient.cs @@ -0,0 +1,292 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Flaro.Core.Metadata; +using Microsoft.Extensions.Logging; + +namespace Flaro.Metadata; + +/// Ob die Seite von Rotten Tomatoes gelesen werden darf. +public interface IRottenTomatoesSettingsSource +{ + Task EingeschaltetAsync(CancellationToken ct = default); +} + +/// Die beiden Werte von Rotten Tomatoes — Kritik und Publikum. +/// +/// +/// +/// Warum das hier steht und nicht bei OMDb. OMDb liefert im Feld +/// Ratings nur eine Zahl je Dienst, und bei Rotten Tomatoes ist das +/// die der Kritik. Die erweiterten Felder (tomatoUserMeter) gibt es +/// zwar, sie stehen aber seit Jahren auf N/A. Jellyfin fragt sie an +/// und liest sie nicht einmal aus — deren Antwortklasse kennt das Feld +/// nicht. Den Publikumswert hat dort also niemand. +/// +/// +/// Die Werte stehen auf der Seite selbst, maschinenlesbar. Gemessen +/// an /m/the_matrix: im Quelltext liegt +/// "audienceScore":{… "score":"85" …} neben +/// "criticsScore":{… "score":"83" …}, dazu ein +/// application/ld+json mit Titel und Erscheinungsdatum. Bei Serien +/// unter /tv/… dasselbe. Ein unbekannter Weg antwortet mit 404. +/// +/// +/// Das ist Auslesen einer Webseite, und das hat seinen Preis. Rotten +/// Tomatoes hat keine offene Schnittstelle — die Suche unter +/// /napi/search antwortet mit 401 —, also wird der Weg aus dem Titel +/// geraten und hinterher geprüft. Ändert sich der Aufbau der Seite, kommt +/// nichts mehr; das ist kein Fehler dieses Clients, sondern die Natur der +/// Sache. Deshalb ist die Quelle ausgeschaltet, bis jemand sie +/// einschaltet, und ein Fehlschlag bleibt still. +/// +/// +/// Geprüft wird über Titel und Jahr, nicht über eine Kennung. Auf der +/// Seite steht keine IMDb-Kennung. Ein geratener Weg kann also auf einen +/// anderen Film führen — /m/the_lion_king ist der von 1994, +/// /m/the_lion_king_2019 der von 2019. Weicht das Jahr um mehr als +/// eines ab, wird der Treffer verworfen; lieber keine Zahl als die eines +/// fremden Films. +/// +/// +public sealed partial class RottenTomatoesClient( + HttpClient http, + IRottenTomatoesSettingsSource einstellungen, + Anklopfbremse bremse, + ILogger log) : IBewertungsquelle +{ + public const string BaseAddress = "https://www.rottentomatoes.com/"; + + /// Wie weit das Jahr abweichen darf, bevor es ein anderer Film ist. + /// + /// + /// Eins, nicht null. Rotten Tomatoes führt das Datum der + /// amerikanischen Erstaufführung, TMDB oft das der Heimatpremiere — ein + /// Film vom Dezember steht dort schon mal im Folgejahr. + /// + private const int JahrSpielraum = 1; + + public Task EingerichtetAsync(CancellationToken ct = default) => + einstellungen.EingeschaltetAsync(ct); + + public async Task> HolenAsync( + Wertungsfrage frage, CancellationToken ct = default) + { + if (!await einstellungen.EingeschaltetAsync(ct)) return []; + if (string.IsNullOrWhiteSpace(frage.Titel)) return []; + + foreach (var weg in Wege(frage)) + { + var seite = await SeiteAsync(weg, ct); + if (seite is null) continue; + + if (!Passt(seite, frage)) + { + log.LogDebug("Rotten Tomatoes: {Weg} ist ein anderer Titel als {Gesucht}.", + weg, frage.Titel); + continue; + } + + var gefunden = Werte(seite); + if (gefunden.Count > 0) return gefunden; + } + + return []; + } + + /// Die Wege, die zu diesem Titel führen könnten — in dieser Reihenfolge. + /// + /// + /// + /// Ohne Jahr zuerst, und das ist gemessen. Der Normalfall trägt + /// den blanken Namen: /m/the_matrix und /tv/dark antworten, + /// /m/the_matrix_1999 und /tv/dark_2017 dagegen mit 404. + /// Fragte man mit Jahr zuerst, kostete jeder gewöhnliche Titel zwei + /// Anfragen statt einer — auf einer Seite, die man ohnehin nur + /// mitbenutzt. + /// + /// + /// Das Jahr ist der zweite Versuch, und der Jahresvergleich der + /// Grund, dass er überhaupt nötig wird. Bei einer Neuverfilmung + /// führt der blanke Name zur alten Fassung — /m/the_lion_king + /// ist die von 1994. Die wird am Jahr erkannt und verworfen, und dann + /// greift /m/the_lion_king_2019. + /// + /// + private static IEnumerable Wege(Wertungsfrage frage) + { + var art = frage.Serie ? "tv" : "m"; + var gesehen = new HashSet(StringComparer.Ordinal); + + // **Der Originaltitel zuerst.** Rotten Tomatoes kennt nur die + // amerikanischen Titel. Steht die Mediathek auf Deutsch, heisst der + // Film bei uns „Ich einfach unverbesserlich" und dort + // `despicable_me` — mit dem deutschen Titel allein fände ein + // deutscher Bestand so gut wie nichts. + foreach (var titel in frage.Titelvarianten) + { + var kurz = Kennstueck(titel); + if (kurz.Length == 0) continue; + + if (gesehen.Add(kurz)) yield return $"{art}/{kurz}"; + if (frage.Jahr is { } jahr && gesehen.Add($"{kurz}_{jahr}")) + yield return $"{art}/{kurz}_{jahr}"; + } + } + + /// „The Lion King (2019)" wird zu „the_lion_king". + /// + /// + /// Buchstaben und Ziffern bleiben, alles andere wird zum Unterstrich. + /// So macht es Rotten Tomatoes selbst: „Spider-Man: No Way Home" steht + /// unter spider_man_no_way_home. Umlaute werden zerlegt — „Ö" + /// wird zu „o" —, denn im Weg steht nie ein Umlaut. + /// + public static string Kennstueck(string titel) + { + var zerlegt = (titel ?? "").Normalize(NormalizationForm.FormD); + var bau = new StringBuilder(zerlegt.Length); + + foreach (var z in zerlegt) + { + if (CharUnicodeInfo.GetUnicodeCategory(z) == UnicodeCategory.NonSpacingMark) continue; + + if (char.IsAsciiLetterOrDigit(z)) bau.Append(char.ToLowerInvariant(z)); + else if (z is 'ß') bau.Append("ss"); + else if (bau.Length > 0 && bau[^1] != '_') bau.Append('_'); + } + + return bau.ToString().Trim('_'); + } + + private async Task SeiteAsync(string weg, CancellationToken ct) + { + try + { + // **Vor jeder Anfrage, nicht nur vor der ersten.** Ein Scan über + // achthundert Titel wäre sonst ein Schwall von einer einzigen + // Adresse — und der wird gesperrt, zu Recht. + await bremse.WartenAsync(ct); + + using var ergebnis = await http.GetAsync(weg, ct); + + // 404 ist eine Antwort: diesen Weg gibt es nicht. Alles andere + // — 403, 429, ein Zwischenportal — ist ein Hindernis, und auch + // das hält keinen Scan auf. + if (!ergebnis.IsSuccessStatusCode) + { + log.LogDebug("Rotten Tomatoes antwortete mit {Code} auf {Weg}.", + ergebnis.StatusCode, weg); + return null; + } + + return await ergebnis.Content.ReadAsStringAsync(ct); + } + catch (Exception fehler) when (fehler is HttpRequestException or TaskCanceledException) + { + log.LogDebug(fehler, "Rotten Tomatoes war für {Weg} nicht zu erreichen.", weg); + return null; + } + } + + /// Ob die Seite wirklich den gesuchten Titel zeigt. + public static bool Passt(string seite, Wertungsfrage frage) + { + var beschrieben = Beschreibung(seite); + if (beschrieben is null) return false; + + var (art, name, datum) = beschrieben.Value; + + // **Film bleibt Film.** Ein geratener Weg unter `/m/` kann auf einer + // Serienseite landen, wenn Rotten Tomatoes umleitet. + var erwartet = frage.Serie ? "TVSeries" : "Movie"; + if (!string.Equals(art, erwartet, StringComparison.Ordinal)) return false; + + // **Das Jahr entscheidet, nicht der Name.** „The Lion King (2019)" + // heisst dort mit Jahr im Namen, „Plan B" bei uns anders als dort. + // Ein Namensvergleich wäre deshalb zu streng; fehlt ein Jahr, bleibt + // nur der Name, und dann wird der Anfang verglichen. + if (frage.Jahr is { } jahr && datum is { } d) + return Math.Abs(d.Year - jahr) <= JahrSpielraum; + + var dort = Kennstueck(name); + return frage.Titelvarianten.Select(Kennstueck).Any(hier => + hier.Length > 0 + && (dort.StartsWith(hier, StringComparison.Ordinal) + || hier.StartsWith(dort, StringComparison.Ordinal))); + } + + /// Art, Name und Datum aus dem ld+json der Seite. + private static (string Art, string Name, DateOnly? Datum)? Beschreibung(string seite) + { + var block = LdJson().Match(seite); + if (!block.Success) return null; + + try + { + using var gelesen = JsonDocument.Parse(block.Groups[1].Value); + var wurzel = gelesen.RootElement; + + var art = wurzel.TryGetProperty("@type", out var a) ? a.GetString() ?? "" : ""; + var name = wurzel.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; + + DateOnly? datum = null; + if (wurzel.TryGetProperty("dateCreated", out var d) + && DateOnly.TryParse(d.GetString(), CultureInfo.InvariantCulture, out var gelesenes)) + { + datum = gelesenes; + } + + return (art, name, datum); + } + catch (JsonException) + { + return null; + } + } + + /// Die beiden Zahlen aus dem Quelltext. + /// + /// + /// Die erste Fundstelle zählt. Auf derselben Seite stehen beide + /// Blöcke mehrfach — einmal ausführlich für die Anzeige, einmal knapp + /// für ein Vorschaufenster. Die Zahl ist in allen dieselbe. + /// + private static List Werte(string seite) + { + var liste = new List(); + + if (Zahl(Kritik().Match(seite)) is { } kritik) + liste.Add(new Fremdwertung("rottenTomatoes", kritik / 10, $"{kritik:0}%")); + + if (Zahl(Publikum().Match(seite)) is { } publikum) + liste.Add(new Fremdwertung("rottenTomatoesAudience", publikum / 10, $"{publikum:0}%")); + + return liste; + } + + private static double? Zahl(Match treffer) + { + if (!treffer.Success) return null; + if (!double.TryParse(treffer.Groups[1].Value, NumberStyles.Float, + CultureInfo.InvariantCulture, out var wert)) return null; + + return wert is >= 0 and <= 100 ? wert : null; + } + + [GeneratedRegex( + """]*type=["']application/ld\+json["'][^>]*>(.*?)""", + RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex LdJson(); + + [GeneratedRegex(""" + "criticsScore":\{[^{}]*?"score":"(\d{1,3})" + """, RegexOptions.IgnorePatternWhitespace)] + private static partial Regex Kritik(); + + [GeneratedRegex(""" + "audienceScore":\{[^{}]*?"score":"(\d{1,3})" + """, RegexOptions.IgnorePatternWhitespace)] + private static partial Regex Publikum(); +} diff --git a/server/tests/Flaro.Metadata.Tests/OmdbTests.cs b/server/tests/Flaro.Metadata.Tests/OmdbTests.cs index 2ec9e6d..386c389 100644 --- a/server/tests/Flaro.Metadata.Tests/OmdbTests.cs +++ b/server/tests/Flaro.Metadata.Tests/OmdbTests.cs @@ -14,6 +14,9 @@ namespace Flaro.Metadata.Tests; /// public class OmdbTests { + /// Wonach gefragt wird — bei OMDb zählt allein die Kennung. + 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), @@ -32,7 +35,7 @@ public class OmdbTests { var attrappe = Attrappe.MitJson(Antwort); - var wertungen = await Client(attrappe, schluessel: null).HolenAsync("tt0000001"); + var wertungen = await Client(attrappe, schluessel: null).HolenAsync(Frage("tt0000001")); Assert.Multiple(() => { @@ -51,7 +54,7 @@ public class OmdbTests [Test] public async Task Alle_drei_Quellen_kommen_normiert_und_lesbar() { - var wertungen = await Client(Attrappe.MitJson(Antwort)).HolenAsync("tt0000001"); + var wertungen = await Client(Attrappe.MitJson(Antwort)).HolenAsync(Frage("tt0000001")); Assert.Multiple(() => { @@ -81,7 +84,7 @@ public class OmdbTests { var json = """{"Response":"False","Error":"Incorrect IMDb ID."}"""; - Assert.That(await Client(Attrappe.MitJson(json)).HolenAsync("tt0000001"), Is.Empty); + Assert.That(await Client(Attrappe.MitJson(json)).HolenAsync(Frage("tt0000001")), Is.Empty); } /// Eine unbekannte vierte Quelle wird übergangen, nicht geraten. @@ -94,7 +97,7 @@ public class OmdbTests {"Source":"Internet Movie Database","Value":"7.4/10"}]} """; - var wertungen = await Client(Attrappe.MitJson(json)).HolenAsync("tt0000001"); + var wertungen = await Client(Attrappe.MitJson(json)).HolenAsync(Frage("tt0000001")); Assert.That(wertungen.Select(w => w.Quelle), Is.EquivalentTo(new[] { "imdb" })); } @@ -103,7 +106,7 @@ public class OmdbTests public async Task Kein_Netz_ergibt_nichts_statt_eines_Absturzes() { var wertungen = await Client(Attrappe.DieWirft(new HttpRequestException("weg"))) - .HolenAsync("tt0000001"); + .HolenAsync(Frage("tt0000001")); Assert.That(wertungen, Is.Empty); } @@ -112,7 +115,7 @@ public class OmdbTests public async Task Ein_Fehlercode_ergibt_nichts() { Assert.That( - await Client(Attrappe.MitStatus(HttpStatusCode.Unauthorized)).HolenAsync("tt0000001"), + await Client(Attrappe.MitStatus(HttpStatusCode.Unauthorized)).HolenAsync(Frage("tt0000001")), Is.Empty); } @@ -120,7 +123,7 @@ public class OmdbTests public async Task Kennung_und_Schluessel_stehen_in_der_Anfrage() { var attrappe = Attrappe.MitJson(Antwort); - await Client(attrappe).HolenAsync("tt0133093"); + await Client(attrappe).HolenAsync(Frage("tt0133093")); Assert.That(attrappe.Aufrufe[0], Does.Contain("i=tt0133093").And.Contain("apikey=ein-schluessel")); @@ -137,7 +140,7 @@ public class OmdbTests public async Task Der_Publikumswert_wird_mitverlangt() { var attrappe = Attrappe.MitJson(Antwort); - await Client(attrappe).HolenAsync("tt0133093"); + await Client(attrappe).HolenAsync(Frage("tt0133093")); Assert.That(attrappe.Aufrufe, Has.Count.EqualTo(1)); Assert.That(attrappe.Aufrufe[0], Does.Contain("tomatoes=true")); @@ -159,7 +162,7 @@ public class OmdbTests "tomatoUserMeter":"88"} """; - var wertungen = await Client(Attrappe.MitJson(json)).HolenAsync("tt0000001"); + var wertungen = await Client(Attrappe.MitJson(json)).HolenAsync(Frage("tt0000001")); Assert.Multiple(() => { @@ -192,7 +195,7 @@ public class OmdbTests "tomatoUserMeter":{{feld}}} """; - var wertungen = await Client(Attrappe.MitJson(json)).HolenAsync("tt0000001"); + var wertungen = await Client(Attrappe.MitJson(json)).HolenAsync(Frage("tt0000001")); Assert.Multiple(() => { @@ -219,7 +222,7 @@ public class OmdbTests "tomatoUserMeter":88} """; - var wertungen = await Client(Attrappe.MitJson(json)).HolenAsync("tt0000001"); + var wertungen = await Client(Attrappe.MitJson(json)).HolenAsync(Frage("tt0000001")); Assert.That(wertungen.Select(w => w.Quelle), Is.EquivalentTo(new[] { "imdb", "rottenTomatoesAudience" })); @@ -236,7 +239,7 @@ public class OmdbTests { var attrappe = Attrappe.MitJson(Antwort); - await Client(attrappe).HolenAsync(""); + await Client(attrappe).HolenAsync(Frage("")); Assert.That(attrappe.Aufrufe, Is.Empty); } diff --git a/server/tests/Flaro.Metadata.Tests/RottenTomatoesTests.cs b/server/tests/Flaro.Metadata.Tests/RottenTomatoesTests.cs new file mode 100644 index 0000000..efd5895 --- /dev/null +++ b/server/tests/Flaro.Metadata.Tests/RottenTomatoesTests.cs @@ -0,0 +1,332 @@ +using Flaro.Core.Metadata; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Flaro.Metadata.Tests; + +/// Die beiden Werte von Rotten Tomatoes, von der Seite selbst gelesen. +/// +/// +/// Gegen einen festgehaltenen Seitenausschnitt, nicht gegen das Netz. +/// Eine Prüfung, die wirklich hinausgeht, misst das Netz und nicht den Code; +/// sie wäre launisch und fiele aus, sobald jemand ohne Verbindung arbeitet. +/// Der Ausschnitt unten ist gekürzt, aber in der Form, die +/// /m/the_matrix am 9. Oktober 2026 geliefert hat. +/// +public class RottenTomatoesTests +{ + private static string Seite( + string art = "Movie", string name = "The Matrix", string datum = "1999-03-31", + string kritik = "83", string publikum = "85") => + $$$""" + + + + + + """; + + private static RottenTomatoesClient Client(Attrappe attrappe, bool an = true) => + new(new HttpClient(attrappe) { BaseAddress = new Uri(RottenTomatoesClient.BaseAddress) }, + new FesterTomatenschalter(an), + // Ohne Pause: die Bremse ist eine Höflichkeit gegenüber der + // echten Seite, und eine Prüfung geht nirgends hin. + new Anklopfbremse(TimeSpan.Zero), + NullLogger.Instance); + + private static Wertungsfrage Matrix => new("tt0133093", "The Matrix", 1999); + + /// Ausgeschaltet heisst: keine einzige Anfrage. + /// + /// + /// Das ist der wichtigste Satz dieser Datei. Hier wird eine fremde + /// Webseite ausgelesen; das darf nicht beiläufig geschehen, weil jemand + /// einen Scan startet. + /// + [Test] + public async Task Ausgeschaltet_geht_keine_Anfrage_hinaus() + { + var attrappe = Attrappe.MitJson(Seite()); + + var wertungen = await Client(attrappe, an: false).HolenAsync(Matrix); + + Assert.Multiple(() => + { + Assert.That(wertungen, Is.Empty); + Assert.That(attrappe.Aufrufe, Is.Empty); + }); + } + + /// Kritik und Publikum kommen getrennt zurück. + [Test] + public async Task Beide_Werte_kommen_getrennt() + { + var wertungen = await Client(Attrappe.MitJson(Seite())).HolenAsync(Matrix); + + Assert.Multiple(() => + { + var kritik = wertungen.Single(w => w.Quelle == "rottenTomatoes"); + Assert.That(kritik.Anzeige, Is.EqualTo("83%")); + Assert.That(kritik.Wert, Is.EqualTo(8.3).Within(0.01)); + + var publikum = wertungen.Single(w => w.Quelle == "rottenTomatoesAudience"); + Assert.That(publikum.Anzeige, Is.EqualTo("85%")); + Assert.That(publikum.Wert, Is.EqualTo(8.5).Within(0.01)); + }); + } + + /// Der gewöhnliche Titel kostet genau eine Anfrage. + /// + /// + /// Gemessen an der echten Seite: /m/the_matrix antwortet, + /// /m/the_matrix_1999 mit 404. Mit Jahr zuerst zu fragen kostete + /// bei jedem gewöhnlichen Titel eine Anfrage zu viel — auf einer Seite, + /// die man ohnehin nur mitbenutzt. + /// + [Test] + public async Task Ein_gewoehnlicher_Titel_kostet_eine_Anfrage() + { + var attrappe = Attrappe.MitJson(Seite()); + + await Client(attrappe).HolenAsync(Matrix); + + Assert.Multiple(() => + { + Assert.That(attrappe.Aufrufe, Has.Count.EqualTo(1)); + Assert.That(attrappe.Aufrufe[0], Does.EndWith("m/the_matrix")); + }); + } + + /// Ein Film mit anderem Jahr ist ein anderer Film. + /// + /// + /// Auf der Seite steht keine IMDb-Kennung. Der Weg wird aus dem + /// Titel geraten, und ein geratener Weg kann überall hinführen. Lieber + /// keine Zahl als die eines fremden Films. + /// + [Test] + public async Task Ein_fremdes_Jahr_wird_verworfen() + { + var attrappe = Attrappe.MitJson(Seite(name: "The Lion King", datum: "1994-06-24")); + + var wertungen = await Client(attrappe) + .HolenAsync(new Wertungsfrage(null, "The Lion King", 2019)); + + Assert.That(wertungen, Is.Empty); + } + + /// Beim falschen Jahr wird der Weg mit Jahr nachgereicht. + /// + /// + /// Die Neuverfilmung ist der einzige Fall, der zwei Anfragen + /// rechtfertigt. /m/the_lion_king ist die von 1994, + /// /m/the_lion_king_2019 die neue. Erst wird die alte am Jahr + /// erkannt und verworfen, dann greift die zweite. + /// + [Test] + public async Task Eine_Neuverfilmung_wird_ueber_den_Weg_mit_Jahr_gefunden() + { + var attrappe = Attrappe.NachPfad( + ("the_lion_king_2019", Seite(name: "The Lion King (2019)", datum: "2019-07-19", + kritik: "52", publikum: "88")), + ("the_lion_king", Seite(name: "The Lion King", datum: "1994-06-24", + kritik: "93", publikum: "93"))); + + var wertungen = await Client(attrappe) + .HolenAsync(new Wertungsfrage(null, "The Lion King", 2019)); + + Assert.Multiple(() => + { + Assert.That(attrappe.Aufrufe, Has.Count.EqualTo(2)); + Assert.That(wertungen.Single(w => w.Quelle == "rottenTomatoes").Anzeige, + Is.EqualTo("52%"), "die Fassung von 2019, nicht die von 1994"); + Assert.That(wertungen.Single(w => w.Quelle == "rottenTomatoesAudience").Anzeige, + Is.EqualTo("88%")); + }); + } + + /// Ein Jahr daneben ist kein anderer Film. + /// + /// + /// Rotten Tomatoes führt das Datum der amerikanischen Erstaufführung, + /// TMDB oft das der Heimatpremiere — ein Film vom Dezember steht dort + /// schon mal im Folgejahr. + /// + [Test] + public async Task Ein_Jahr_Unterschied_zaehlt_noch_als_derselbe() + { + var wertungen = await Client(Attrappe.MitJson(Seite(datum: "2000-01-20"))) + .HolenAsync(Matrix); + + Assert.That(wertungen, Has.Count.EqualTo(2)); + } + + /// Eine Serie darf nicht auf einer Filmseite landen. + [Test] + public async Task Eine_Filmseite_beantwortet_keine_Seriensuche() + { + var wertungen = await Client(Attrappe.MitJson(Seite(art: "Movie", name: "Dark"))) + .HolenAsync(new Wertungsfrage(null, "Dark", 1999, Serie: true)); + + Assert.That(wertungen, Is.Empty); + } + + [Test] + public async Task Eine_Serie_wird_unter_tv_gesucht() + { + var attrappe = Attrappe.MitJson( + Seite(art: "TVSeries", name: "Dark", datum: "2017-12-01")); + + var wertungen = await Client(attrappe) + .HolenAsync(new Wertungsfrage(null, "Dark", 2017, Serie: true)); + + Assert.Multiple(() => + { + Assert.That(attrappe.Aufrufe[0], Does.EndWith("tv/dark"), + "Serien stehen dort ohne Jahr: /tv/dark_2017 antwortet mit 404."); + Assert.That(wertungen, Has.Count.EqualTo(2)); + }); + } + + /// Ein unbekannter Weg endet mit 404 — und das ist eine Antwort. + [Test] + public async Task Ein_unbekannter_Weg_ergibt_nichts() + { + var wertungen = await Client(Attrappe.MitStatus(System.Net.HttpStatusCode.NotFound)) + .HolenAsync(Matrix); + + Assert.That(wertungen, Is.Empty); + } + + /// Eine Sperrseite ohne die bekannten Felder ergibt nichts, keinen Absturz. + [Test] + public async Task Eine_umgebaute_Seite_ergibt_nichts() + { + var wertungen = await Client(Attrappe.MitJson("Nichts hier.")) + .HolenAsync(Matrix); + + Assert.That(wertungen, Is.Empty); + } + + [Test] + public async Task Kein_Netz_ergibt_nichts_statt_eines_Absturzes() + { + var wertungen = await Client(Attrappe.DieWirft(new HttpRequestException("weg"))) + .HolenAsync(Matrix); + + Assert.That(wertungen, Is.Empty); + } + + /// Der Originaltitel wird zuerst versucht. + /// + /// + /// Sonst fände ein deutscher Bestand dort so gut wie nichts. Steht + /// die Mediathek auf Deutsch, heisst der Film bei uns „Ich einfach + /// unverbesserlich"; Rotten Tomatoes kennt nur despicable_me. + /// + [Test] + public async Task Der_Originaltitel_wird_zuerst_versucht() + { + var attrappe = Attrappe.NachPfad( + ("despicable_me", Seite(name: "Despicable Me", datum: "2010-07-09", + kritik: "81", publikum: "84"))); + + var wertungen = await Client(attrappe).HolenAsync(new Wertungsfrage( + null, "Ich einfach unverbesserlich", 2010, Originaltitel: "Despicable Me")); + + Assert.Multiple(() => + { + Assert.That(attrappe.Aufrufe[0], Does.EndWith("m/despicable_me")); + Assert.That(wertungen.Single(w => w.Quelle == "rottenTomatoesAudience").Anzeige, + Is.EqualTo("84%")); + }); + } + + /// Gibt es den Originaltitel dort nicht, kommt der deutsche dran. + [Test] + public async Task Ohne_Treffer_wird_auch_der_andere_Titel_versucht() + { + var attrappe = Attrappe.NachPfad( + ("ich_einfach_unverbesserlich", + Seite(name: "Ich einfach unverbesserlich", datum: "2010-07-09"))); + + var wertungen = await Client(attrappe).HolenAsync(new Wertungsfrage( + null, "Ich einfach unverbesserlich", 2010, Originaltitel: "Despicable Me")); + + Assert.Multiple(() => + { + Assert.That(attrappe.Aufrufe[0], Does.Contain("despicable_me"), "der originale zuerst"); + Assert.That(wertungen, Has.Count.EqualTo(2)); + }); + } + + /// Derselbe Titel zweimal ergibt nicht zwei Anfragen. + [Test] + public async Task Ein_gleicher_Originaltitel_kostet_nichts_extra() + { + var attrappe = Attrappe.MitJson(Seite()); + + await Client(attrappe).HolenAsync( + new Wertungsfrage(null, "The Matrix", 1999, Originaltitel: "The Matrix")); + + Assert.That(attrappe.Aufrufe, Has.Count.EqualTo(1)); + } + + /// Einmal wirklich hinaus — nur von Hand. + /// + /// + /// + /// [Explicit], und das ist der Punkt. Diese Prüfung misst + /// das Netz und eine fremde Seite, nicht den Code; im Durchlauf wäre sie + /// launisch und fiele ohne Verbindung aus. Von Hand ist sie dagegen das + /// einzige, was die Frage beantwortet, ob das Auslesen heute noch geht: + /// + /// + /// dotnet test --filter "FullyQualifiedName~Wirklich_von_der_echten_Seite" + /// + /// + [Test, Explicit("Geht wirklich ins Netz.")] + public async Task Wirklich_von_der_echten_Seite_gelesen() + { + using var echt = new HttpClient { BaseAddress = new Uri(RottenTomatoesClient.BaseAddress) }; + echt.DefaultRequestHeaders.UserAgent.ParseAdd( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " + + "Chrome/131.0.0.0 Safari/537.36"); + + var client = new RottenTomatoesClient( + echt, new FesterTomatenschalter(true), new Anklopfbremse(TimeSpan.Zero), + NullLogger.Instance); + + var wertungen = await client.HolenAsync(Matrix); + + Assert.Multiple(() => + { + Assert.That(wertungen.Select(w => w.Quelle), + Is.EquivalentTo(new[] { "rottenTomatoes", "rottenTomatoesAudience" })); + TestContext.Out.WriteLine(string.Join(", ", + wertungen.Select(w => $"{w.Quelle} {w.Anzeige}"))); + }); + } + + /// So baut Rotten Tomatoes seine Wege. + [TestCase("The Matrix", ExpectedResult = "the_matrix")] + [TestCase("Spider-Man: No Way Home", ExpectedResult = "spider_man_no_way_home")] + [TestCase("WALL·E", ExpectedResult = "wall_e")] + [TestCase("Ich einfach unverbesserlich", ExpectedResult = "ich_einfach_unverbesserlich")] + [TestCase("Österreich – Grüße", ExpectedResult = "osterreich_grusse")] + [TestCase(" ", ExpectedResult = "")] + public string Aus_dem_Titel_wird_ein_Weg(string titel) => + RottenTomatoesClient.Kennstueck(titel); +} + +public sealed record FesterTomatenschalter(bool An) : IRottenTomatoesSettingsSource +{ + public Task EingeschaltetAsync(CancellationToken ct = default) => Task.FromResult(An); +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 65559d6..cf9f19c 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -1298,6 +1298,9 @@ export const metadatenEinstellung = () => hasFanartKey: boolean; /** Ob eine Bewertungsquelle (OMDb) eingerichtet ist. */ hasOmdbKey: boolean; + /** Ob die Seite von Rotten Tomatoes gelesen werden darf — ein Schalter, + * kein Schlüssel. */ + rottenTomatoes: boolean; }>('/settings/metadata'); /** Fragt die Quellen einmal und sagt, was zurückkam. */ @@ -1311,6 +1314,7 @@ export const quellenProbe = () => export const metadatenSetzen = (werte: { apiKey?: string; language?: string; fanartApiKey?: string; omdbApiKey?: string; + rottenTomatoes?: boolean; }) => anfrage('/settings/metadata', { method: 'PUT', body: JSON.stringify(werte) }); diff --git a/web/app/src/views/einstellungen/metadaten.ts b/web/app/src/views/einstellungen/metadaten.ts index 9b987b6..95e8068 100644 --- a/web/app/src/views/einstellungen/metadaten.ts +++ b/web/app/src/views/einstellungen/metadaten.ts @@ -89,11 +89,26 @@ export function sprachbereich(): HTMLElement { tmdb: stand.hasApiKey || stand.usingProjectKey, fanart: stand.hasFanartKey, omdb: stand.hasOmdbKey, + rottenTomatoes: stand.rottenTomatoes, }; quellenliste.innerHTML = ''; QUELLEN.forEach(q => { + // **Ein Schalter, kein Schlüsselfeld.** Ein Dialog mit einem leeren + // Eingabefeld fragte nach etwas, das es dort nicht zu holen gibt. + if (q.schalter) { + const an = hinterlegt[q.kennung]; + quellenliste.appendChild(zeile( + q.name, an ? 'Eingeschaltet' : 'Ausgeschaltet', `${q.was} ${q.hinweis}`, + async () => { + await metadatenSetzen({ rottenTomatoes: !an }); + await quellenLaden(); + }, + an ? 'Ausschalten' : 'Einschalten')); + return; + } + const wert = q.kennung === 'tmdb' && stand!.usingProjectKey ? 'Mitgelieferter Zugang' : hinterlegt[q.kennung] ? 'Eigener Zugang hinterlegt' : 'Nicht eingerichtet'; @@ -101,7 +116,7 @@ export function sprachbereich(): HTMLElement { quellenliste.appendChild(zeile(q.name, wert, q.was, () => dialog({ titel: q.name, felder: [{ - name: 'schluessel', beschriftung: q.feld, art: 'password', + name: 'schluessel', beschriftung: q.feld ?? 'Schlüssel', art: 'password', platzhalter: hinterlegt[q.kennung] ? 'Hinterlegt — leer lassen, um ihn zu behalten' : '', hinweis: q.hinweis, }], @@ -271,8 +286,19 @@ export function sprachbereich(): HTMLElement { /* ---------- Die Quellen ---------- */ +type Quellenangabe = { + kennung: 'tmdb' | 'fanart' | 'omdb' | 'rottenTomatoes'; + name: string; + was: string; + hinweis: string; + /** Die Beschriftung des Schlüsselfeldes — fehlt bei einer, die keinen hat. */ + feld?: string; + /** Kein Schlüssel, sondern an oder aus. */ + schalter?: boolean; +}; + /** Was jede Quelle liefert und was sie dafür braucht. */ -const QUELLEN = [ +const QUELLEN: Quellenangabe[] = [ { kennung: 'tmdb' as const, name: 'TMDB', @@ -295,4 +321,15 @@ const QUELLEN = [ feld: 'Persönlicher API-Schlüssel', hinweis: 'Kostenlos bis tausend Abrufe am Tag — deshalb kein mitgelieferter.', }, + { + kennung: 'rottenTomatoes' as const, + name: 'Rotten Tomatoes', + was: 'Beide Werte: die Wertung der Kritik und die des Publikums. ' + + 'OMDb gibt nur die der Kritik weiter.', + schalter: true, + hinweis: 'Dort gibt es keine Zugänge zu vergeben — die Zahlen stehen nur auf der Seite ' + + 'selbst und werden von dort gelesen. Das geschieht ohne Einverständnis des Betreibers ' + + 'und hört auf zu wirken, sobald die Seite umgebaut wird. Deshalb ist es ausgeschaltet, ' + + 'bis du es einschaltest.', + }, ]; diff --git a/web/app/src/views/einstellungen/teile.ts b/web/app/src/views/einstellungen/teile.ts index 165c55d..e509985 100644 --- a/web/app/src/views/einstellungen/teile.ts +++ b/web/app/src/views/einstellungen/teile.ts @@ -30,6 +30,10 @@ export function abschnitt(titel: string, knoepfe: HTMLElement[] = []): HTMLEleme */ export function zeile( name: string, wert: string, hinweis: string | null, bearbeiten?: () => void, + /** Steht auf dem Knopf. **Ein Stift gehört nur auf „Ändern".** Ein Schalter + * ändert nichts, er schaltet um; das Zeichen soll nicht etwas anderes + * versprechen als der Text daneben. */ + knopftext?: string, ): HTMLElement { const d = document.createElement('div'); d.className = 'einstellung'; @@ -43,8 +47,8 @@ export function zeile( oben.appendChild(text); if (bearbeiten) { - const b = knopf('Ändern', 'btn'); - b.prepend(icon('stift')); + const b = knopf(knopftext ?? 'Ändern', 'btn'); + if (!knopftext) b.prepend(icon('stift')); b.onclick = bearbeiten; oben.appendChild(b); }