Der Publikumswert steht auf der Seite, also wird er von dort gelesen

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>
This commit is contained in:
Scarriffle
2026-10-09 16:51:22 +02:00
co-authored by Claude Opus 5
parent e05bbfc2cd
commit 8397e042b8
16 changed files with 965 additions and 36 deletions
+11 -2
View File
@@ -571,7 +571,15 @@ public sealed record Saisonreihe(string? Occasion, Page<MovieResponse> Items);
/// </param>
public sealed record MetadataSettingsResponse(
bool HasApiKey, string Language, bool UsingProjectKey,
bool HasFanartKey = false, bool HasOmdbKey = false);
bool HasFanartKey = false, bool HasOmdbKey = false,
/// <summary>Ob die Seite von Rotten Tomatoes gelesen werden darf.</summary>
///
/// <remarks>
/// <b>Kein Schlüssel, sondern ein Schalter.</b> Dort gibt es keine
/// Zugänge zu vergeben; eingeschaltet wird das Auslesen der Seite.
/// </remarks>
bool RottenTomatoes = false);
/// <summary>Was ein Client über der Mediathek anzeigen muss.</summary>
///
@@ -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);
/// <summary>Was der Server über die Wiedergabe entscheidet.</summary>
///
@@ -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();
+28
View File
@@ -88,3 +88,31 @@ public sealed class DatabaseOmdbSettings(FlaroContext db) : IOmdbSettingsSource
return _gemerkt = string.IsNullOrWhiteSpace(zeile?.Value) ? null : zeile.Value.Trim();
}
}
/// <summary>Ob die Seite von Rotten Tomatoes gelesen werden darf.</summary>
///
/// <remarks>
/// <b>Kein Schlüssel, sondern ein Schalter — und der steht auf aus.</b>
/// 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.
/// </remarks>
public sealed class DatabaseRottenTomatoesSettings(FlaroContext db) : IRottenTomatoesSettingsSource
{
public const string SchalterName = "metadata.rottenTomatoes.enabled";
private bool? _gemerkt;
public async Task<bool> 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;
}
}
+30 -1
View File
@@ -123,7 +123,36 @@ builder.Services.AddHttpClient<OmdbClient>(c =>
c.BaseAddress = new Uri(OmdbClient.BaseAddress);
c.Timeout = TimeSpan.FromSeconds(20);
});
builder.Services.AddScoped<IBewertungsquelle>(sp => sp.GetRequiredService<OmdbClient>());
// **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<IRottenTomatoesSettingsSource, DatabaseRottenTomatoesSettings>();
// **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<RottenTomatoesClient>(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<IBewertungsquelle>(sp => new Bewertungsquellen(
[
sp.GetRequiredService<OmdbClient>(),
sp.GetRequiredService<RottenTomatoesClient>(),
]));
// **Damit man sieht, ob ein Schlüssel wirkt.** „Hinterlegt" ist keine
// Auskunft darüber, ob der Dienst antwortet.
+39 -4
View File
@@ -32,17 +32,21 @@ public sealed class Quellenprobe(
TmdbClient tmdb,
FanartClient fanart,
OmdbClient omdb,
RottenTomatoesClient tomaten,
ITmdbSettingsSource spracheVon)
{
/// <summary>„The Matrix" — bei allen drei Quellen vorhanden.</summary>
/// <summary>„The Matrix" — bei allen Quellen vorhanden.</summary>
private const int ProbeTmdbId = 603;
private const string ProbeImdbId = "tt0133093";
private const string ProbeTitel = "The Matrix";
private const int ProbeJahr = 1999;
public async Task<IReadOnlyList<Quellenbefund>> LaufenAsync(CancellationToken ct = default) =>
[
await TmdbProbe(ct),
await FanartProbe(ct),
await OmdbProbe(ct),
await TomatenProbe(ct),
];
private async Task<Quellenbefund> 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)}.");
}
/// <summary>Die Seite von Rotten Tomatoes, falls das Lesen erlaubt wurde.</summary>
///
/// <remarks>
/// <b>Hier ist die Probe mehr als eine Höflichkeit.</b> 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.
/// </remarks>
private async Task<Quellenbefund> 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<Fremdwertung> wertungen) =>
string.Join(", ", wertungen.Select(w => $"{Kurz(w.Quelle)} {w.Anzeige}"));
private static string Kurz(string quelle) => quelle switch
{
"imdb" => "IMDb",
@@ -318,8 +318,43 @@ public interface IBewertungsquelle
{
Task<bool> EingerichtetAsync(CancellationToken ct = default);
/// <summary>Alle Bewertungen zu einer IMDb-Kennung.</summary>
Task<IReadOnlyList<Fremdwertung>> HolenAsync(string imdbId, CancellationToken ct = default);
/// <summary>Alle Bewertungen zu einem Titel.</summary>
Task<IReadOnlyList<Fremdwertung>> HolenAsync(Wertungsfrage frage, CancellationToken ct = default);
}
/// <summary>Wonach eine Bewertungsquelle gefragt wird.</summary>
///
/// <param name="ImdbId">Die IMDb-Kennung, falls bekannt.</param>
/// <param name="Titel">Der Titel, wie die Metadatenquelle ihn führt.</param>
/// <param name="Jahr">Das Erscheinungsjahr, falls bekannt.</param>
/// <param name="Serie">Ob es eine Serie ist und kein Film.</param>
/// <param name="Originaltitel">
/// Der Titel in der Sprache des Originals, falls er abweicht.
///
/// <para>
/// <b>Ohne ihn fände ein deutscher Bestand bei Rotten Tomatoes fast
/// nichts.</b> Steht die Mediathek auf Deutsch, heisst der Film hier „Ich
/// einfach unverbesserlich"; dort gibt es nur <c>despicable_me</c>.
/// </para>
/// </param>
///
/// <remarks>
/// <b>Nicht jede Quelle kennt denselben Weg hinein.</b> 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.
/// </remarks>
public sealed record Wertungsfrage(
string? ImdbId, string Titel, int? Jahr, bool Serie = false, string? Originaltitel = null)
{
/// <summary>Beide Titel, der originale zuerst — ohne Leeres und ohne Dubletten.</summary>
public IReadOnlyList<string> Titelvarianten =>
new[] { Originaltitel, Titel }
.Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => t!.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
/// <summary>Wie Bilder nach Sprache geordnet werden.</summary>
+13 -8
View File
@@ -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.
///
/// <b>Ohne IMDb-Kennung geht gar nichts.</b> OMDb kennt nur diesen Weg;
/// einen Titel zu suchen hiesse, denselben Zuordnungsfehler ein zweites
/// Mal zu riskieren.
/// <b>Jede Quelle nimmt einen anderen Weg hinein.</b> 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.
/// </remarks>
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
@@ -0,0 +1,50 @@
using System.Diagnostics;
namespace Flaro.Metadata;
/// <summary>Lässt höchstens eine Anfrage je Zeitspanne durch.</summary>
///
/// <remarks>
/// <para>
/// <b>Für eine Seite, die man mitbenutzt.</b> 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.
/// </para>
/// <para>
/// <b>Eine Warteschlange, kein Verwerfen.</b> Wer zu früh kommt, wartet;
/// niemand verliert seine Anfrage. Ein Scan wird dadurch langsamer — das
/// ist der Preis, und er ist gewollt.
/// </para>
/// <para>
/// <b><see cref="Stopwatch"/> und nicht die Uhrzeit.</b> Eine Zeitumstellung
/// oder ein Abgleich mit dem Zeitserver liesse die Bremse sonst entweder
/// eine Stunde sperren oder gar nicht greifen.
/// </para>
/// </remarks>
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();
}
}
}
@@ -0,0 +1,56 @@
using Flaro.Core.Metadata;
namespace Flaro.Metadata;
/// <summary>Mehrere Bewertungsquellen als eine.</summary>
///
/// <param name="quellen">
/// In der Reihenfolge, in der sie gefragt werden. <b>Die spätere gewinnt</b>,
/// wenn beide dieselbe Zahl führen.
/// </param>
///
/// <remarks>
/// <para>
/// <b>Warum nicht einfach zwei Felder im Einleser.</b> Der kennt dann beide
/// Quellen beim Namen, und jede dritte verlangt wieder eine Änderung an ihm.
/// So fragt er eine und bekommt alles.
/// </para>
/// <para>
/// <b>Die spätere gewinnt, und das ist kein Zufall.</b> 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.
/// </para>
/// <para>
/// <b>Eine Quelle, die nichts liefert, nimmt nichts weg.</b> Eine leere
/// Antwort ersetzt keine vorhandene Zahl — sonst löschte ein Aussetzer bei
/// Rotten Tomatoes die Bewertungen, die OMDb schon geliefert hat.
/// </para>
/// </remarks>
public sealed class Bewertungsquellen(IReadOnlyList<IBewertungsquelle> quellen) : IBewertungsquelle
{
public async Task<bool> EingerichtetAsync(CancellationToken ct = default)
{
foreach (var q in quellen)
{
if (await q.EingerichtetAsync(ct)) return true;
}
return false;
}
public async Task<IReadOnlyList<Fremdwertung>> HolenAsync(
Wertungsfrage frage, CancellationToken ct = default)
{
var nachQuelle = new Dictionary<string, Fremdwertung>(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];
}
}
+2 -1
View File
@@ -55,8 +55,9 @@ public sealed class OmdbClient(
!string.IsNullOrWhiteSpace(await einstellungen.SchluesselAsync(ct));
public async Task<IReadOnlyList<Fremdwertung>> 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 [];
@@ -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;
/// <summary>Ob die Seite von Rotten Tomatoes gelesen werden darf.</summary>
public interface IRottenTomatoesSettingsSource
{
Task<bool> EingeschaltetAsync(CancellationToken ct = default);
}
/// <summary>Die beiden Werte von Rotten Tomatoes — Kritik und Publikum.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum das hier steht und nicht bei OMDb.</b> OMDb liefert im Feld
/// <c>Ratings</c> nur eine Zahl je Dienst, und bei Rotten Tomatoes ist das
/// die der Kritik. Die erweiterten Felder (<c>tomatoUserMeter</c>) gibt es
/// zwar, sie stehen aber seit Jahren auf <c>N/A</c>. Jellyfin fragt sie an
/// und liest sie nicht einmal aus — deren Antwortklasse kennt das Feld
/// nicht. Den Publikumswert hat dort also niemand.
/// </para>
/// <para>
/// <b>Die Werte stehen auf der Seite selbst, maschinenlesbar.</b> Gemessen
/// an <c>/m/the_matrix</c>: im Quelltext liegt
/// <c>"audienceScore":{… "score":"85" …}</c> neben
/// <c>"criticsScore":{… "score":"83" …}</c>, dazu ein
/// <c>application/ld+json</c> mit Titel und Erscheinungsdatum. Bei Serien
/// unter <c>/tv/…</c> dasselbe. Ein unbekannter Weg antwortet mit 404.
/// </para>
/// <para>
/// <b>Das ist Auslesen einer Webseite, und das hat seinen Preis.</b> Rotten
/// Tomatoes hat keine offene Schnittstelle — die Suche unter
/// <c>/napi/search</c> 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 <b>ausgeschaltet, bis jemand sie
/// einschaltet</b>, und ein Fehlschlag bleibt still.
/// </para>
/// <para>
/// <b>Geprüft wird über Titel und Jahr, nicht über eine Kennung.</b> Auf der
/// Seite steht keine IMDb-Kennung. Ein geratener Weg kann also auf einen
/// anderen Film führen — <c>/m/the_lion_king</c> ist der von 1994,
/// <c>/m/the_lion_king_2019</c> der von 2019. Weicht das Jahr um mehr als
/// eines ab, wird der Treffer verworfen; lieber keine Zahl als die eines
/// fremden Films.
/// </para>
/// </remarks>
public sealed partial class RottenTomatoesClient(
HttpClient http,
IRottenTomatoesSettingsSource einstellungen,
Anklopfbremse bremse,
ILogger<RottenTomatoesClient> log) : IBewertungsquelle
{
public const string BaseAddress = "https://www.rottentomatoes.com/";
/// <summary>Wie weit das Jahr abweichen darf, bevor es ein anderer Film ist.</summary>
///
/// <remarks>
/// <b>Eins, nicht null.</b> 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.
/// </remarks>
private const int JahrSpielraum = 1;
public Task<bool> EingerichtetAsync(CancellationToken ct = default) =>
einstellungen.EingeschaltetAsync(ct);
public async Task<IReadOnlyList<Fremdwertung>> 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 [];
}
/// <summary>Die Wege, die zu diesem Titel führen könnten — in dieser Reihenfolge.</summary>
///
/// <remarks>
/// <para>
/// <b>Ohne Jahr zuerst, und das ist gemessen.</b> Der Normalfall trägt
/// den blanken Namen: <c>/m/the_matrix</c> und <c>/tv/dark</c> antworten,
/// <c>/m/the_matrix_1999</c> und <c>/tv/dark_2017</c> 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.
/// </para>
/// <para>
/// <b>Das Jahr ist der zweite Versuch, und der Jahresvergleich der
/// Grund, dass er überhaupt nötig wird.</b> Bei einer Neuverfilmung
/// führt der blanke Name zur alten Fassung — <c>/m/the_lion_king</c>
/// ist die von 1994. Die wird am Jahr erkannt und verworfen, und dann
/// greift <c>/m/the_lion_king_2019</c>.
/// </para>
/// </remarks>
private static IEnumerable<string> Wege(Wertungsfrage frage)
{
var art = frage.Serie ? "tv" : "m";
var gesehen = new HashSet<string>(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}";
}
}
/// <summary>„The Lion King (2019)" wird zu „the_lion_king".</summary>
///
/// <remarks>
/// <b>Buchstaben und Ziffern bleiben, alles andere wird zum Unterstrich.</b>
/// So macht es Rotten Tomatoes selbst: „Spider-Man: No Way Home" steht
/// unter <c>spider_man_no_way_home</c>. Umlaute werden zerlegt — „Ö"
/// wird zu „o" —, denn im Weg steht nie ein Umlaut.
/// </remarks>
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<string?> 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;
}
}
/// <summary>Ob die Seite wirklich den gesuchten Titel zeigt.</summary>
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)));
}
/// <summary>Art, Name und Datum aus dem <c>ld+json</c> der Seite.</summary>
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;
}
}
/// <summary>Die beiden Zahlen aus dem Quelltext.</summary>
///
/// <remarks>
/// <b>Die erste Fundstelle zählt.</b> 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.
/// </remarks>
private static List<Fremdwertung> Werte(string seite)
{
var liste = new List<Fremdwertung>();
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(
"""<script[^>]*type=["']application/ld\+json["'][^>]*>(.*?)</script>""",
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();
}
+15 -12
View File
@@ -14,6 +14,9 @@ namespace Flaro.Metadata.Tests;
/// </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),
@@ -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);
}
/// <summary>Eine unbekannte vierte Quelle wird übergangen, nicht geraten.</summary>
@@ -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);
}
@@ -0,0 +1,332 @@
using Flaro.Core.Metadata;
using Microsoft.Extensions.Logging.Abstractions;
namespace Flaro.Metadata.Tests;
/// <summary>Die beiden Werte von Rotten Tomatoes, von der Seite selbst gelesen.</summary>
///
/// <remarks>
/// <b>Gegen einen festgehaltenen Seitenausschnitt, nicht gegen das Netz.</b>
/// 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
/// <c>/m/the_matrix</c> am 9. Oktober 2026 geliefert hat.
/// </remarks>
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") =>
$$$"""
<html><head>
<script type="application/ld+json">
{"@context":"http://schema.org","@type":"{{{art}}}","name":"{{{name}}}",
"dateCreated":"{{{datum}}}","url":"https://www.rottentomatoes.com/m/matrix"}
</script>
</head><body>
<script>window.__data = {"audienceScore":{"averageRating":"3.6",
"likedCount":142778,"score":"{{{publikum}}}","scoreType":"ALL",
"sentiment":"POSITIVE","scorePercent":"{{{publikum}}}%","title":"Popcornmeter"},
"criticsScore":{"averageRating":"7.90","certified":true,
"score":"{{{kritik}}}","sentiment":"POSITIVE","scorePercent":"{{{kritik}}}%"}};
</script>
</body></html>
""";
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<RottenTomatoesClient>.Instance);
private static Wertungsfrage Matrix => new("tt0133093", "The Matrix", 1999);
/// <summary>Ausgeschaltet heisst: keine einzige Anfrage.</summary>
///
/// <remarks>
/// <b>Das ist der wichtigste Satz dieser Datei.</b> Hier wird eine fremde
/// Webseite ausgelesen; das darf nicht beiläufig geschehen, weil jemand
/// einen Scan startet.
/// </remarks>
[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);
});
}
/// <summary>Kritik und Publikum kommen getrennt zurück.</summary>
[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));
});
}
/// <summary>Der gewöhnliche Titel kostet genau eine Anfrage.</summary>
///
/// <remarks>
/// <b>Gemessen an der echten Seite:</b> <c>/m/the_matrix</c> antwortet,
/// <c>/m/the_matrix_1999</c> 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.
/// </remarks>
[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"));
});
}
/// <summary>Ein Film mit anderem Jahr ist ein anderer Film.</summary>
///
/// <remarks>
/// <b>Auf der Seite steht keine IMDb-Kennung.</b> Der Weg wird aus dem
/// Titel geraten, und ein geratener Weg kann überall hinführen. Lieber
/// keine Zahl als die eines fremden Films.
/// </remarks>
[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);
}
/// <summary>Beim falschen Jahr wird der Weg mit Jahr nachgereicht.</summary>
///
/// <remarks>
/// <b>Die Neuverfilmung ist der einzige Fall, der zwei Anfragen
/// rechtfertigt.</b> <c>/m/the_lion_king</c> ist die von 1994,
/// <c>/m/the_lion_king_2019</c> die neue. Erst wird die alte am Jahr
/// erkannt und verworfen, dann greift die zweite.
/// </remarks>
[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%"));
});
}
/// <summary>Ein Jahr daneben ist kein anderer Film.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
[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));
}
/// <summary>Eine Serie darf nicht auf einer Filmseite landen.</summary>
[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));
});
}
/// <summary>Ein unbekannter Weg endet mit 404 — und das ist eine Antwort.</summary>
[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);
}
/// <summary>Eine Sperrseite ohne die bekannten Felder ergibt nichts, keinen Absturz.</summary>
[Test]
public async Task Eine_umgebaute_Seite_ergibt_nichts()
{
var wertungen = await Client(Attrappe.MitJson("<html><body>Nichts hier.</body></html>"))
.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);
}
/// <summary>Der Originaltitel wird zuerst versucht.</summary>
///
/// <remarks>
/// <b>Sonst fände ein deutscher Bestand dort so gut wie nichts.</b> Steht
/// die Mediathek auf Deutsch, heisst der Film bei uns „Ich einfach
/// unverbesserlich"; Rotten Tomatoes kennt nur <c>despicable_me</c>.
/// </remarks>
[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%"));
});
}
/// <summary>Gibt es den Originaltitel dort nicht, kommt der deutsche dran.</summary>
[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));
});
}
/// <summary>Derselbe Titel zweimal ergibt nicht zwei Anfragen.</summary>
[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));
}
/// <summary>Einmal wirklich hinaus — nur von Hand.</summary>
///
/// <remarks>
/// <para>
/// <b><c>[Explicit]</c>, und das ist der Punkt.</b> 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:
/// </para>
/// <para>
/// <c>dotnet test --filter "FullyQualifiedName~Wirklich_von_der_echten_Seite"</c>
/// </para>
/// </remarks>
[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<RottenTomatoesClient>.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}")));
});
}
/// <summary>So baut Rotten Tomatoes seine Wege.</summary>
[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<bool> EingeschaltetAsync(CancellationToken ct = default) => Task.FromResult(An);
}
+4
View File
@@ -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<void>('/settings/metadata', { method: 'PUT', body: JSON.stringify(werte) });
+39 -2
View File
@@ -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.',
},
];
+6 -2
View File
@@ -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);
}