IMDb, Rotten Tomatoes und Metacritic neben der TMDB-Wertung

IMDbs eigene Schnittstelle kostet Geld, Rotten Tomatoes vergibt Zugänge nur
an Partner, Letterboxd hat gar keine offene. OMDb liefert alle drei in
einer Antwort, über die IMDb-Kennung, die ohnehin schon an jedem Film steht.

**Eine Tabelle und kein Feld je Quelle.** `Movie.Rating` ist ein Skalar
ohne Quellenangabe, und das war richtig, solange es eine Quelle gab. Für
drei weitere wären es drei Spalten — und bei der vierten wieder eine
Migration. Jetzt ist jede Quelle eine Zeile, je Titel und Quelle eine, mit
eindeutigem Index darauf: ein zweiter Lauf schreibt um statt anzuhäufen.

**Zwei Werte je Bewertung, und beide gebraucht.** Der normierte Wert zum
Vergleichen und Sortieren, die Anzeige zum Hinschreiben. Rotten Tomatoes in
„8,7 von 10" umzurechnen wäre eine Lüge über die Quelle — dort steht 87 %,
und genau so kennt man es.

**Wieder keine zweite Metadatenquelle.** `IBewertungsquelle` liefert
Bewertungen, sonst nichts. Bei Titel und Beschreibung wären zwei Anbieter
zwei Wahrheiten; bei einer Bewertung ist jede Quelle eine eigene Zahl, und
da sind mehrere kein Widerspruch, sondern der Sinn der Sache.

**Ohne Kennung wird nicht gefragt.** OMDb kennt nur den Weg über die
IMDb-Kennung; nach einem Titel zu suchen hiesse, denselben Zuordnungsfehler
ein zweites Mal zu riskieren. Und auf Unbekanntes antwortet OMDb mit 200
und `Response: "False"` — das als Treffer durchzulassen legte eine leere
Bewertung ab, die danach als „keine Wertung" dastünde.

Angezeigt wird mit Kürzel statt Stern: vier gleich aussehende Zahlen
nebeneinander sagen nicht, woher sie kommen. Kein Logo — die Marken von
IMDb und Rotten Tomatoes mitzuliefern hiesse, fremde Zeichen auszuliefern.

Kein mitgelieferter Schlüssel, aus demselben Grund wie bei fanart.tv: OMDb
begrenzt auf tausend Abrufe am Tag je Schlüssel. Einzutragen unter
Einstellungen → Übersicht → Metadaten.

740 Tests grün, davon acht neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-08 21:26:58 +02:00
co-authored by Claude Opus 5
parent 97edb49564
commit af2b844d65
18 changed files with 1869 additions and 10 deletions
+29 -4
View File
@@ -436,7 +436,16 @@ public sealed record MovieResponse(
string? SortTitle = null,
/// <summary>Ob der Sortiertitel von Hand gesetzt wurde.</summary>
bool SortTitlePinned = false);
bool SortTitlePinned = false,
/// <summary>Bewertungen von anderswo — leer, wenn keine Quelle eingerichtet ist.</summary>
///
/// <remarks>
/// <b>Neben <c>Rating</c>, nicht statt dessen.</b> Das Feld ist die
/// Wertung von TMDB und bleibt, wo es ist; ein Client, der nur das
/// kennt, zeigt weiter dasselbe wie bisher.
/// </remarks>
IReadOnlyList<Fremdwertungsantwort>? ExternalRatings = null);
public sealed record CreditResponse(
Guid Id, string Name, string Role, string? Character, string? Image);
@@ -498,7 +507,16 @@ public sealed record SeriesResponse(
string? SortTitle = null,
/// <summary>Ob der Sortiertitel von Hand gesetzt wurde.</summary>
bool SortTitlePinned = false);
bool SortTitlePinned = false,
/// <summary>Bewertungen von anderswo — leer, wenn keine Quelle eingerichtet ist.</summary>
///
/// <remarks>
/// <b>Neben <c>Rating</c>, nicht statt dessen.</b> Das Feld ist die
/// Wertung von TMDB und bleibt, wo es ist; ein Client, der nur das
/// kennt, zeigt weiter dasselbe wie bisher.
/// </remarks>
IReadOnlyList<Fremdwertungsantwort>? ExternalRatings = null);
public sealed record EpisodeResponse(
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
@@ -524,7 +542,8 @@ public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
/// selbst nicht zurück — zum Anzeigen genügt, dass einer da ist.
/// </param>
public sealed record MetadataSettingsResponse(
bool HasApiKey, string Language, bool UsingProjectKey, bool HasFanartKey = false);
bool HasApiKey, string Language, bool UsingProjectKey,
bool HasFanartKey = false, bool HasOmdbKey = false);
/// <summary>Was ein Client über der Mediathek anzeigen muss.</summary>
///
@@ -537,7 +556,7 @@ 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? ApiKey, string? Language, string? FanartApiKey = null, string? OmdbApiKey = null);
/// <summary>Was der Server über die Wiedergabe entscheidet.</summary>
///
@@ -720,6 +739,12 @@ public sealed record StreamResponse(
int? AudioStream,
IReadOnlyList<SpurAuskunft> AudioStreams,
IReadOnlyList<SpurAuskunft> SubtitleStreams);
/// <summary>Eine Bewertung von anderswo, fertig zum Hinschreiben.</summary>
///
/// <param name="Source">„imdb", „rottenTomatoes", „metacritic".</param>
/// <param name="Display">Was dastehen soll — in der Form, die man von der Quelle kennt.</param>
public sealed record Fremdwertungsantwort(string Source, double Value, string Display);
public sealed record RatingRequest(int? Rating);
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
@@ -168,6 +168,21 @@ public static class LibraryContentEndpoints
}
/// <summary>Die Genres eines Titels, in der Reihenfolge der Quelle.</summary>
/// <summary>Die Bewertungen von anderswo zu einem Titel.</summary>
///
/// <remarks>
/// <b>Nur beim Einzelabruf.</b> In einer Liste mit fünfzig Kacheln
/// stünden sie nirgends, kosteten aber fünfzig Abfragen — dieselbe
/// Überlegung wie bei Genres und Besetzung.
/// </remarks>
private static async Task<IReadOnlyList<Fremdwertungsantwort>> FremdwertungenZu(
FlaroContext db, Guid? film, Guid? serie) =>
await db.Fremdbewertungen.AsNoTracking()
.Where(b => film != null ? b.MovieId == film : b.SeriesId == serie)
.OrderByDescending(b => b.Wert)
.Select(b => new Fremdwertungsantwort(b.Quelle, b.Wert, b.Anzeige))
.ToListAsync();
private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) =>
await db.GenreLinks.AsNoTracking()
.Where(l => film != null ? l.MovieId == film : l.SeriesId == serie)
@@ -639,7 +654,8 @@ public static class LibraryContentEndpoints
TitlePinned: film.TitlePinned,
SortTitle: film.SortTitleValue,
SortTitlePinned: film.SortTitlePinned,
PublishAt: film.PublishAt));
PublishAt: film.PublishAt,
ExternalRatings: await FremdwertungenZu(db, film: id, serie: null)));
}).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}", async (
@@ -674,7 +690,8 @@ public static class LibraryContentEndpoints
TitlePinned: serie.TitlePinned,
SortTitle: serie.SortTitleValue,
SortTitlePinned: serie.SortTitlePinned,
PublishAt: folgen.Min(f => f.PublishAt)));
PublishAt: folgen.Min(f => f.PublishAt),
ExternalRatings: await FremdwertungenZu(db, film: null, serie: id)));
}).RequireAuthorization();
// **Eine Folge für sich.**
@@ -17,7 +17,8 @@ public static class SettingsEndpoints
var zeilen = await db.Settings.AsNoTracking()
.Where(s => s.Key == DatabaseTmdbSettings.ApiKeyName
|| s.Key == DatabaseTmdbSettings.LanguageName
|| s.Key == DatabaseFanartSettings.ApiKeyName)
|| s.Key == DatabaseFanartSettings.ApiKeyName
|| s.Key == DatabaseOmdbSettings.ApiKeyName)
.ToDictionaryAsync(s => s.Key, s => s.Value);
// **Der Schlüssel selbst kommt nicht zurück.** Er gäbe jedem
@@ -34,7 +35,9 @@ public static class SettingsEndpoints
// benutzt wird — und dass ein eigener ihn ablösen würde.
UsingProjectKey: aufgelöst.UsingProjectKey,
HasFanartKey: !string.IsNullOrWhiteSpace(
zeilen.GetValueOrDefault(DatabaseFanartSettings.ApiKeyName))));
zeilen.GetValueOrDefault(DatabaseFanartSettings.ApiKeyName)),
HasOmdbKey: !string.IsNullOrWhiteSpace(
zeilen.GetValueOrDefault(DatabaseOmdbSettings.ApiKeyName))));
}).RequireAuthorization(FlaroPolicies.Administrator);
// **In welcher Sprache der Bestand vorliegt.**
@@ -94,6 +97,8 @@ public static class SettingsEndpoints
if (anfrage.ApiKey is not null) await Setzen(db, DatabaseTmdbSettings.ApiKeyName, anfrage.ApiKey.Trim());
if (anfrage.FanartApiKey is not null)
await Setzen(db, DatabaseFanartSettings.ApiKeyName, anfrage.FanartApiKey.Trim());
if (anfrage.OmdbApiKey is not null)
await Setzen(db, DatabaseOmdbSettings.ApiKeyName, anfrage.OmdbApiKey.Trim());
if (anfrage.Language is not null)
{
var sprache = anfrage.Language.Trim();
+26
View File
@@ -62,3 +62,29 @@ public sealed class DatabaseFanartSettings(FlaroContext db) : IFanartSettingsSou
return _gemerkt = string.IsNullOrWhiteSpace(zeile?.Value) ? null : zeile.Value.Trim();
}
}
/// <summary>Der Schlüssel für OMDb, aus der Datenbank.</summary>
///
/// <remarks>
/// Wie bei fanart.tv: kein mitgelieferter. OMDb begrenzt je Schlüssel auf
/// tausend Abrufe am Tag — einen fremden mitzuliefern hiesse, dessen
/// Kontingent zu verbrauchen.
/// </remarks>
public sealed class DatabaseOmdbSettings(FlaroContext db) : IOmdbSettingsSource
{
public const string ApiKeyName = "metadata.omdb.apiKey";
private string? _gemerkt;
private bool _gelesen;
public async Task<string?> SchluesselAsync(CancellationToken ct = default)
{
if (_gelesen) return _gemerkt;
var zeile = await db.Settings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == ApiKeyName, ct);
_gelesen = true;
return _gemerkt = string.IsNullOrWhiteSpace(zeile?.Value) ? null : zeile.Value.Trim();
}
}
+12
View File
@@ -111,6 +111,18 @@ builder.Services.AddHttpClient<FanartClient>(c =>
c.Timeout = TimeSpan.FromSeconds(20);
});
builder.Services.AddScoped<IBildquelle>(sp => sp.GetRequiredService<FanartClient>());
// **Und eine für Bewertungen.** IMDbs eigene Schnittstelle kostet Geld,
// Rotten Tomatoes vergibt Zugänge nur an Partner, Letterboxd hat gar keine
// offene — OMDb liefert alle drei über die IMDb-Kennung, die ohnehin schon
// an jedem Film steht.
builder.Services.AddScoped<IOmdbSettingsSource, DatabaseOmdbSettings>();
builder.Services.AddHttpClient<OmdbClient>(c =>
{
c.BaseAddress = new Uri(OmdbClient.BaseAddress);
c.Timeout = TimeSpan.FromSeconds(20);
});
builder.Services.AddScoped<IBewertungsquelle>(sp => sp.GetRequiredService<OmdbClient>());
builder.Services.AddSingleton(new ImageCacheOptions(pfade.Cache));
builder.Services.AddHttpClient<ImageCache>(c => c.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddScoped<IImageStore>(sp => sp.GetRequiredService<ImageCache>());