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:
co-authored by
Claude Opus 5
parent
97edb49564
commit
af2b844d65
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+1185
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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user