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>());
@@ -64,3 +64,36 @@ public sealed class Credit
/// </remarks>
public int Order { get; set; }
}
/// <summary>Eine Bewertung von anderswo — IMDb, Rotten Tomatoes, Metacritic.</summary>
///
/// <remarks>
/// <para>
/// <b>Eine Tabelle und kein Feld je Quelle.</b> <c>Movie.Rating</c> 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. So ist jede Quelle eine Zeile.
/// </para>
/// <para>
/// <b>Zwei Werte, und beide gebraucht.</b> <see cref="Wert"/> ist auf 0 bis
/// 10 gebracht, damit sich Quellen vergleichen und sortieren lassen;
/// <see cref="Anzeige"/> ist, was dastehen soll. Rotten Tomatoes in „8,7 von
/// 10" umzurechnen wäre eine Lüge über die Quelle — dort steht 87 %, und
/// genau das kennt man von dort.
/// </para>
/// </remarks>
public sealed class Fremdbewertung
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public Guid? MovieId { get; init; }
public Guid? SeriesId { get; init; }
/// <summary>„imdb", „rottenTomatoes" oder „metacritic".</summary>
public required string Quelle { get; set; }
/// <summary>Auf 0 bis 10 gebracht — zum Vergleichen, nicht zum Anzeigen.</summary>
public double Wert { get; set; }
/// <summary>Was dastehen soll: „7,4", „87 %", „62".</summary>
public required string Anzeige { get; set; }
}
@@ -280,6 +280,30 @@ public interface IBildquelle
Task<IReadOnlyList<Bildkandidat>> FilmbilderAsync(int tmdbId, CancellationToken ct = default);
}
/// <summary>Eine Bewertung von anderswo.</summary>
///
/// <param name="Quelle">„imdb", „rottenTomatoes", „metacritic".</param>
/// <param name="Wert">Auf 0 bis 10 gebracht — zum Vergleichen.</param>
/// <param name="Anzeige">Was dastehen soll: „7,4", „87 %", „62".</param>
public sealed record Fremdwertung(string Quelle, double Wert, string Anzeige);
/// <summary>Eine Quelle, die nur Bewertungen liefert.</summary>
///
/// <remarks>
/// <b>Wieder nicht <see cref="IMetadataProvider"/>.</b> Aus demselben Grund
/// wie bei <see cref="IBildquelle"/>: dessen Methoden liefern ganze
/// Titelangaben, und zwei Anbieter für Titel und Beschreibung wären zwei
/// Wahrheiten. Eine Bewertung dagegen ist je Quelle eine eigene Zahl — da
/// sind mehrere Quellen kein Widerspruch, sondern der Sinn der Sache.
/// </remarks>
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>Wie Bilder nach Sprache geordnet werden.</summary>
///
/// <remarks>
+14
View File
@@ -75,6 +75,7 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
public DbSet<UserItemState> UserItemStates => Set<UserItemState>();
public DbSet<Genre> Genres => Set<Genre>();
public DbSet<GenreLink> GenreLinks => Set<GenreLink>();
public DbSet<Fremdbewertung> Fremdbewertungen => Set<Fremdbewertung>();
public DbSet<Person> People => Set<Person>();
public DbSet<Credit> Credits => Set<Credit>();
public DbSet<ItemTranslation> Translations => Set<ItemTranslation>();
@@ -271,6 +272,19 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
e.Property(x => x.Name).HasMaxLength(64).UseCollation("NOCASE");
});
b.Entity<Fremdbewertung>(e =>
{
e.HasKey(x => x.Id);
// Eine Quelle einmal je Titel: ein zweiter Lauf schreibt um,
// statt eine zweite Zeile anzulegen.
e.HasIndex(x => new { x.MovieId, x.Quelle }).IsUnique().HasFilter("\"MovieId\" IS NOT NULL");
e.HasIndex(x => new { x.SeriesId, x.Quelle }).IsUnique().HasFilter("\"SeriesId\" IS NOT NULL");
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
e.Property(x => x.Quelle).HasMaxLength(32);
e.Property(x => x.Anzeige).HasMaxLength(32);
});
b.Entity<GenreLink>(e =>
{
e.HasKey(x => x.Id);
+47
View File
@@ -30,6 +30,7 @@ public sealed class MetadataEnricher(
IImageStore bilder,
IImageReader bildLeser,
IBildquelle zweiteBildquelle,
IBewertungsquelle bewertungen,
ISidecarStore beiwerk,
Zusammenlegung zusammenlegung,
TimeProvider uhr,
@@ -242,6 +243,7 @@ public sealed class MetadataEnricher(
film.ContentRating = gefunden.ContentRating;
film.MatchLocked = festgelegt;
await BewertungenSetzen(film.Id, null, gefunden.ImdbId, ct);
await GenresSetzen(gefunden.Genres, film: film.Id, serie: null, ct);
await MitwirkendeSetzen(gefunden.Credits, film: film.Id, serie: null, ct);
zugeordnet++;
@@ -399,6 +401,7 @@ public sealed class MetadataEnricher(
serie.Tagline = gefunden.Tagline;
serie.ContentRating = gefunden.ContentRating;
await BewertungenSetzen(null, serie.Id, gefunden.ImdbId, ct);
await GenresSetzen(gefunden.Genres, film: null, serie: serie.Id, ct);
await MitwirkendeSetzen(gefunden.Credits, film: null, serie: serie.Id, ct);
@@ -710,6 +713,50 @@ public sealed class MetadataEnricher(
return new RemoteImage(treffer.Url);
}
/// <summary>Holt die Bewertungen von anderswo und schreibt sie um.</summary>
///
/// <remarks>
/// <b>Umschreiben, nicht anhäufen.</b> Je Titel und Quelle eine Zeile;
/// ein zweiter Lauf ändert sie, statt eine zweite anzulegen. Der
/// 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.
/// </remarks>
private async Task BewertungenSetzen(
Guid? film, Guid? serie, string? imdbId, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(imdbId)) return;
if (!await bewertungen.EingerichtetAsync(ct)) return;
var gefunden = await bewertungen.HolenAsync(imdbId, ct);
if (gefunden.Count == 0) return;
var vorhanden = await db.Fremdbewertungen
.Where(b => film != null ? b.MovieId == film : b.SeriesId == serie)
.ToListAsync(ct);
foreach (var w in gefunden)
{
var zeile = vorhanden.FirstOrDefault(b => b.Quelle == w.Quelle);
if (zeile is null)
{
db.Fremdbewertungen.Add(new Fremdbewertung
{
MovieId = film, SeriesId = serie,
Quelle = w.Quelle, Wert = w.Wert, Anzeige = w.Anzeige,
});
}
else
{
zeile.Wert = w.Wert;
zeile.Anzeige = w.Anzeige;
}
}
}
private async Task<string?> BildAblegen(RemoteImage? bild, CancellationToken ct) =>
bild is null ? null : await bilder.StoreAsync(bild, ct);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Fremdbewertungen : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Fremdbewertungen",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
MovieId = table.Column<Guid>(type: "TEXT", nullable: true),
SeriesId = table.Column<Guid>(type: "TEXT", nullable: true),
Quelle = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Wert = table.Column<double>(type: "REAL", nullable: false),
Anzeige = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Fremdbewertungen", x => x.Id);
table.ForeignKey(
name: "FK_Fremdbewertungen_Movies_MovieId",
column: x => x.MovieId,
principalTable: "Movies",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Fremdbewertungen_Series_SeriesId",
column: x => x.SeriesId,
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Fremdbewertungen_MovieId_Quelle",
table: "Fremdbewertungen",
columns: new[] { "MovieId", "Quelle" },
unique: true,
filter: "\"MovieId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Fremdbewertungen_SeriesId_Quelle",
table: "Fremdbewertungen",
columns: new[] { "SeriesId", "Quelle" },
unique: true,
filter: "\"SeriesId\" IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Fremdbewertungen");
}
}
}
@@ -265,6 +265,44 @@ namespace Flaro.Data.Migrations
b.ToTable("Episodes");
});
modelBuilder.Entity("Flaro.Core.Library.Fremdbewertung", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Anzeige")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Quelle")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.Property<double>("Wert")
.HasColumnType("REAL");
b.HasKey("Id");
b.HasIndex("MovieId", "Quelle")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("SeriesId", "Quelle")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("Fremdbewertungen");
});
modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
{
b.Property<Guid>("Id")
@@ -1004,6 +1042,19 @@ namespace Flaro.Data.Migrations
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Fremdbewertung", b =>
{
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.HasOne("Flaro.Core.Library.Genre", null)
+137
View File
@@ -0,0 +1,137 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Flaro.Core.Metadata;
using Microsoft.Extensions.Logging;
namespace Flaro.Metadata;
/// <summary>Woher der Schlüssel für OMDb kommt.</summary>
public interface IOmdbSettingsSource
{
Task<string?> SchluesselAsync(CancellationToken ct = default);
}
/// <summary>Bewertungen von IMDb, Rotten Tomatoes und Metacritic.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum OMDb und nicht die Quellen selbst.</b> IMDbs eigene
/// Schnittstelle kostet Geld, Rotten Tomatoes vergibt Zugänge nur an
/// Partner, und Letterboxd hat gar keine offene. OMDb liefert alle drei in
/// einer Antwort, über die IMDb-Kennung, die Flaro ohnehin schon führt.
/// </para>
/// <para>
/// <b>Zwei Werte je Bewertung, und beide gebraucht.</b> Der normierte Wert
/// zum Vergleichen, 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 das kennt man von dort.
/// </para>
/// <para>
/// <b>Ohne Schlüssel geschieht nichts.</b> OMDb verlangt einen
/// persönlichen, kostenlosen; einen fremden mitzuliefern hiesse, dessen
/// Tageskontingent zu verbrauchen.
/// </para>
/// </remarks>
public sealed class OmdbClient(
HttpClient http,
IOmdbSettingsSource einstellungen,
ILogger<OmdbClient> log) : IBewertungsquelle
{
public const string BaseAddress = "https://www.omdbapi.com/";
public async Task<bool> EingerichtetAsync(CancellationToken ct = default) =>
!string.IsNullOrWhiteSpace(await einstellungen.SchluesselAsync(ct));
public async Task<IReadOnlyList<Fremdwertung>> HolenAsync(
string imdbId, CancellationToken ct = default)
{
var schluessel = await einstellungen.SchluesselAsync(ct);
if (string.IsNullOrWhiteSpace(schluessel) || string.IsNullOrWhiteSpace(imdbId)) return [];
OmdbAntwort? antwort;
try
{
using var ergebnis = await http.GetAsync(
$"?i={Uri.EscapeDataString(imdbId)}&apikey={Uri.EscapeDataString(schluessel)}", ct);
if (!ergebnis.IsSuccessStatusCode)
{
log.LogDebug("OMDb antwortete mit {Code} für {Kennung}.", ergebnis.StatusCode, imdbId);
return [];
}
antwort = await ergebnis.Content.ReadFromJsonOderNull<OmdbAntwort>(ct);
}
catch (Exception fehler) when (fehler is HttpRequestException or TaskCanceledException or JsonException)
{
// Eine fremde Quelle, die hakt, darf keinen Scan anhalten.
log.LogDebug(fehler, "OMDb war für {Kennung} nicht zu erreichen.", imdbId);
return [];
}
// OMDb antwortet auf Unbekanntes mit 200 und `Response: "False"`.
if (antwort is null || !string.Equals(antwort.Response, "True", StringComparison.OrdinalIgnoreCase))
return [];
var liste = new List<Fremdwertung>();
foreach (var r in antwort.Ratings ?? [])
{
var quelle = Quelle(r.Source);
if (quelle is null || string.IsNullOrWhiteSpace(r.Value)) continue;
if (Lesen(quelle, r.Value) is not { } wert) continue;
liste.Add(new Fremdwertung(quelle, wert, Anzeige(quelle, r.Value)));
}
return liste;
}
/// <summary>Die Namen, die OMDb benutzt, auf unsere Kennungen.</summary>
private static string? Quelle(string? name) => name switch
{
"Internet Movie Database" => "imdb",
"Rotten Tomatoes" => "rottenTomatoes",
"Metacritic" => "metacritic",
// Käme eine vierte dazu, stünde sie hier — bis dahin wird sie
// übergangen statt mit falschem Namen abgelegt.
_ => null,
};
/// <summary>Bringt „8.7/10", „87%" und „62/100" auf dieselbe Skala.</summary>
///
/// <remarks>
/// Nur zum Vergleichen. Angezeigt wird, was die Quelle sagt — siehe
/// <see cref="Anzeige"/>.
/// </remarks>
private static double? Lesen(string quelle, string roh)
{
var zahl = roh.Split('/')[0].TrimEnd('%');
if (!double.TryParse(zahl, NumberStyles.Float, CultureInfo.InvariantCulture, out var wert)) return null;
return quelle switch
{
"imdb" => wert,
"rottenTomatoes" => wert / 10,
"metacritic" => wert / 10,
_ => null,
};
}
/// <summary>Was dastehen soll — in der Form, die man von der Quelle kennt.</summary>
private static string Anzeige(string quelle, string roh) => quelle switch
{
"imdb" => roh.Split('/')[0].Replace('.', ','),
"rottenTomatoes" => roh,
_ => roh.Split('/')[0],
};
private sealed record OmdbAntwort(
[property: JsonPropertyName("Response")] string? Response,
[property: JsonPropertyName("Ratings")] List<OmdbWertung>? Ratings);
private sealed record OmdbWertung(
[property: JsonPropertyName("Source")] string? Source,
[property: JsonPropertyName("Value")] string? Value);
}
@@ -0,0 +1,149 @@
using System.Net;
using Flaro.Core.Metadata;
using Microsoft.Extensions.Logging.Abstractions;
namespace Flaro.Metadata.Tests;
/// <summary>Bewertungen von IMDb, Rotten Tomatoes und Metacritic.</summary>
///
/// <remarks>
/// <b>Warum OMDb und nicht die Quellen selbst.</b> 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 Flaro ohnehin schon führt.
/// </remarks>
public class OmdbTests
{
private static OmdbClient Client(Attrappe attrappe, string? schluessel = "ein-schluessel") =>
new(new HttpClient(attrappe) { BaseAddress = new Uri(OmdbClient.BaseAddress) },
new FesterOmdbSchluessel(schluessel),
NullLogger<OmdbClient>.Instance);
private const string Antwort = """
{"Title":"Probe","Response":"True",
"Ratings":[
{"Source":"Internet Movie Database","Value":"7.4/10"},
{"Source":"Rotten Tomatoes","Value":"87%"},
{"Source":"Metacritic","Value":"62/100"}]}
""";
[Test]
public async Task Ohne_Schluessel_geht_keine_Anfrage_hinaus()
{
var attrappe = Attrappe.MitJson(Antwort);
var wertungen = await Client(attrappe, schluessel: null).HolenAsync("tt0000001");
Assert.Multiple(() =>
{
Assert.That(wertungen, Is.Empty);
Assert.That(attrappe.Aufrufe, Is.Empty);
});
}
/// <summary>Jede Quelle kommt mit zwei Werten: einem zum Vergleichen, einem zum Anzeigen.</summary>
///
/// <remarks>
/// <b>Rotten Tomatoes in „8,7 von 10" umzurechnen wäre eine Lüge über
/// die Quelle.</b> Dort steht 87 %, und genau so kennt man es. Zum
/// Sortieren braucht es trotzdem eine gemeinsame Skala — deshalb beides.
/// </remarks>
[Test]
public async Task Alle_drei_Quellen_kommen_normiert_und_lesbar()
{
var wertungen = await Client(Attrappe.MitJson(Antwort)).HolenAsync("tt0000001");
Assert.Multiple(() =>
{
var imdb = wertungen.Single(w => w.Quelle == "imdb");
Assert.That(imdb.Wert, Is.EqualTo(7.4).Within(0.01));
Assert.That(imdb.Anzeige, Is.EqualTo("7,4"));
var rt = wertungen.Single(w => w.Quelle == "rottenTomatoes");
Assert.That(rt.Wert, Is.EqualTo(8.7).Within(0.01));
Assert.That(rt.Anzeige, Is.EqualTo("87%"), "so steht es dort, und so kennt man es");
var mc = wertungen.Single(w => w.Quelle == "metacritic");
Assert.That(mc.Wert, Is.EqualTo(6.2).Within(0.01));
Assert.That(mc.Anzeige, Is.EqualTo("62"));
});
}
/// <summary>OMDb antwortet auf Unbekanntes mit 200 und „Response: False".</summary>
///
/// <remarks>
/// Das als Treffer durchzulassen hiesse, eine leere Bewertung abzulegen
/// — und die stünde danach als „keine Wertung" da, obwohl es nur keine
/// Antwort gab.
/// </remarks>
[Test]
public async Task Eine_abschlaegige_Antwort_ergibt_nichts()
{
var json = """{"Response":"False","Error":"Incorrect IMDb ID."}""";
Assert.That(await Client(Attrappe.MitJson(json)).HolenAsync("tt0000001"), Is.Empty);
}
/// <summary>Eine unbekannte vierte Quelle wird übergangen, nicht geraten.</summary>
[Test]
public async Task Eine_fremde_Quelle_wird_uebergangen()
{
var json = """
{"Response":"True","Ratings":[
{"Source":"Irgendwas Neues","Value":"5/5"},
{"Source":"Internet Movie Database","Value":"7.4/10"}]}
""";
var wertungen = await Client(Attrappe.MitJson(json)).HolenAsync("tt0000001");
Assert.That(wertungen.Select(w => w.Quelle), Is.EquivalentTo(new[] { "imdb" }));
}
[Test]
public async Task Kein_Netz_ergibt_nichts_statt_eines_Absturzes()
{
var wertungen = await Client(Attrappe.DieWirft(new HttpRequestException("weg")))
.HolenAsync("tt0000001");
Assert.That(wertungen, Is.Empty);
}
[Test]
public async Task Ein_Fehlercode_ergibt_nichts()
{
Assert.That(
await Client(Attrappe.MitStatus(HttpStatusCode.Unauthorized)).HolenAsync("tt0000001"),
Is.Empty);
}
[Test]
public async Task Kennung_und_Schluessel_stehen_in_der_Anfrage()
{
var attrappe = Attrappe.MitJson(Antwort);
await Client(attrappe).HolenAsync("tt0133093");
Assert.That(attrappe.Aufrufe[0],
Does.Contain("i=tt0133093").And.Contain("apikey=ein-schluessel"));
}
/// <summary>Ohne IMDb-Kennung wird gar nicht erst gefragt.</summary>
///
/// <remarks>
/// OMDb kennt nur diesen Weg; nach einem Titel zu suchen hiesse, denselben
/// Zuordnungsfehler ein zweites Mal zu riskieren.
/// </remarks>
[Test]
public async Task Ohne_Kennung_geht_keine_Anfrage_hinaus()
{
var attrappe = Attrappe.MitJson(Antwort);
await Client(attrappe).HolenAsync("");
Assert.That(attrappe.Aufrufe, Is.Empty);
}
}
public sealed record FesterOmdbSchluessel(string? Wert) : IOmdbSettingsSource
{
public Task<string?> SchluesselAsync(CancellationToken ct = default) => Task.FromResult(Wert);
}
+12 -1
View File
@@ -54,6 +54,9 @@ export type Mitwirkender = {
character: string | null; image: string | null;
};
/** Eine Bewertung von anderswo — Quelle, Zahl zum Vergleichen, Text zum Anzeigen. */
export type Fremdwertung = { source: string; value: number; display: string };
export type Film = {
id: string; title: string; year: number | null; overview: string | null;
posterImage: string | null; backdropImage: string | null; files: Datei[];
@@ -61,6 +64,8 @@ export type Film = {
logoImage: string | null;
runtimeMinutes: number | null;
rating: number | null;
/** Leer in Listen, gefüllt beim Einzelabruf — und nur, wenn eine Quelle eingerichtet ist. */
externalRatings: Fremdwertung[] | null;
tagline: string | null;
contentRating: string | null;
/** Leer in Listen, gefüllt beim Einzelabruf. */
@@ -86,6 +91,8 @@ export type Serie = {
logoImage: string | null;
runtimeMinutes: number | null;
rating: number | null;
/** Leer in Listen, gefüllt beim Einzelabruf — und nur, wenn eine Quelle eingerichtet ist. */
externalRatings: Fremdwertung[] | null;
tagline: string | null;
contentRating: string | null;
genres: string[] | null;
@@ -1264,9 +1271,13 @@ export const metadatenEinstellung = () =>
hasApiKey: boolean; language: string; usingProjectKey: boolean;
/** Ob eine zweite Bildquelle (fanart.tv) eingerichtet ist. */
hasFanartKey: boolean;
/** Ob eine Bewertungsquelle (OMDb) eingerichtet ist. */
hasOmdbKey: boolean;
}>('/settings/metadata');
export const metadatenSetzen = (werte: { apiKey?: string; language?: string; fanartApiKey?: string }) =>
export const metadatenSetzen = (werte: {
apiKey?: string; language?: string; fanartApiKey?: string; omdbApiKey?: string;
}) =>
anfrage<void>('/settings/metadata', { method: 'PUT', body: JSON.stringify(werte) });
/* ---------- Zuordnung ---------- */
+16
View File
@@ -647,6 +647,22 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
.bewertung { display: flex; align-items: center; gap: 5px; color: var(--text); font-weight: 600; }
.bewertung svg { width: 15px; height: 15px; color: var(--accent); }
/* **Fremde Bewertungen: Kürzel statt Stern.**
*
* Der Stern gehört der Wertung von TMDB; ihn für drei weitere Quellen zu
* wiederholen hiesse, vier gleich aussehende Zahlen nebeneinanderzustellen,
* von denen keine sagt, woher sie kommt. Das Kürzel sagt es in zwei Zeichen.
*
* **Und kein Logo.** Die Marken von IMDb und Rotten Tomatoes mitzuliefern
* hiesse, fremde Zeichen auszuliefern — mit allem, was daran hängt. */
.bewertung.fremd { font-weight: 500; }
.bewertungsquelle {
font-size: 11px; font-weight: 700; letter-spacing: .03em;
color: var(--text-muted);
border: 1px solid var(--line); border-radius: var(--r-control);
padding: 1px 5px;
}
.werbezeile { color: var(--text-muted); font-style: italic; margin: 0 0 var(--s-loose); }
.beschreibung { max-width: 70ch; margin: 0 0 var(--s-section); color: var(--text-dim); }
+30
View File
@@ -22,6 +22,19 @@ type Art = 'filme' | 'serien';
* Schriftzug des Films statt der Systemschrift, darunter Eckdaten,
* Bewertung, Knöpfe, Beschreibung, die Medienangaben als Tabelle und zuletzt
* die Besetzung mit runden Porträts. */
/** Wie die fremden Quellen heissen — kurz an der Kachel, lang im Tooltip.
*
* **Kein Logo.** Die Marken von IMDb und Rotten Tomatoes mitzuliefern
* hiesse, fremde Zeichen auszuliefern; ein Kürzel sagt dasselbe und gehört
* uns. */
const QUELLENKUERZEL: Record<string, string> = {
imdb: 'IMDb', rottenTomatoes: 'RT', metacritic: 'MC',
};
const QUELLENNAMEN: Record<string, string> = {
imdb: 'IMDb', rottenTomatoes: 'Rotten Tomatoes', metacritic: 'Metacritic',
};
export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElement {
const wurzel = document.createElement('div');
wurzel.className = 'detail';
@@ -238,6 +251,23 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
zeile.appendChild(b);
}
// **Fremde Bewertungen daneben, nicht statt dessen.**
//
// Jede Quelle in ihrer eigenen Form: IMDb zählt bis zehn, Rotten
// Tomatoes in Prozent, Metacritic bis hundert. „87 %" in „8,7 von 10"
// umzurechnen wäre eine Lüge über die Quelle — man kennt sie so, wie
// sie dort steht.
(eintrag.externalRatings ?? []).forEach(w => {
const b = document.createElement('span');
b.className = 'bewertung fremd';
const name = document.createElement('span');
name.className = 'bewertungsquelle';
name.textContent = QUELLENKUERZEL[w.source] ?? w.source;
b.append(name, document.createTextNode(w.display));
b.title = `Bewertung bei ${QUELLENNAMEN[w.source] ?? w.source}`;
zeile.appendChild(b);
});
return zeile;
}
+14 -1
View File
@@ -202,7 +202,8 @@ export function serverbereich(geaendert: () => void): HTMLElement {
(stand.usingProjectKey ? 'Mitgelieferter TMDB-Zugang'
: stand.hasApiKey ? 'Eigener TMDB-Zugang'
: 'Kein Zugang hinterlegt')
+ (stand.hasFanartKey ? ' · fanart.tv für Plakate' : ''),
+ (stand.hasFanartKey ? ' · fanart.tv für Plakate' : '')
+ (stand.hasOmdbKey ? ' · OMDb für Bewertungen' : ''),
stand.usingProjectKey
? 'Ein eigener Zugang löst den mitgelieferten ab.'
: 'Nach einer Sprachänderung die Metadaten auffrischen — '
@@ -228,12 +229,24 @@ export function serverbereich(geaendert: () => void): HTMLElement {
+ 'für Filme — der Serienweg von fanart.tv braucht eine '
+ 'TVDB-Kennung, die Flaro nicht führt.',
},
{
name: 'omdb', beschriftung: 'OMDb (Bewertungen)', art: 'password',
platzhalter: stand.hasOmdbKey ? 'Hinterlegt — leer lassen, um ihn zu behalten' : 'Persönlicher API-Schlüssel',
// 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.
hinweis: 'Liefert IMDb, Rotten Tomatoes und Metacritic in einem '
+ 'Abruf. Kostenlos bis tausend Abrufe am Tag, deshalb kein '
+ 'mitgelieferter Schlüssel.',
},
],
bestaetigen: 'Speichern',
tun: async wert => {
await metadatenSetzen({
apiKey: wert('schluessel') || undefined,
fanartApiKey: wert('fanart') || undefined,
omdbApiKey: wert('omdb') || undefined,
});
location.reload();
},