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, string? SortTitle = null,
/// <summary>Ob der Sortiertitel von Hand gesetzt wurde.</summary> /// <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( public sealed record CreditResponse(
Guid Id, string Name, string Role, string? Character, string? Image); Guid Id, string Name, string Role, string? Character, string? Image);
@@ -498,7 +507,16 @@ public sealed record SeriesResponse(
string? SortTitle = null, string? SortTitle = null,
/// <summary>Ob der Sortiertitel von Hand gesetzt wurde.</summary> /// <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( public sealed record EpisodeResponse(
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage, 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. /// selbst nicht zurück — zum Anzeigen genügt, dass einer da ist.
/// </param> /// </param>
public sealed record MetadataSettingsResponse( 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> /// <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 AttributionResponse(string Text, string LogoUrl, string Url);
public sealed record MetadataSettingsRequest( 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> /// <summary>Was der Server über die Wiedergabe entscheidet.</summary>
/// ///
@@ -720,6 +739,12 @@ public sealed record StreamResponse(
int? AudioStream, int? AudioStream,
IReadOnlyList<SpurAuskunft> AudioStreams, IReadOnlyList<SpurAuskunft> AudioStreams,
IReadOnlyList<SpurAuskunft> SubtitleStreams); 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); public sealed record RatingRequest(int? Rating);
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary> /// <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 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) => private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) =>
await db.GenreLinks.AsNoTracking() await db.GenreLinks.AsNoTracking()
.Where(l => film != null ? l.MovieId == film : l.SeriesId == serie) .Where(l => film != null ? l.MovieId == film : l.SeriesId == serie)
@@ -639,7 +654,8 @@ public static class LibraryContentEndpoints
TitlePinned: film.TitlePinned, TitlePinned: film.TitlePinned,
SortTitle: film.SortTitleValue, SortTitle: film.SortTitleValue,
SortTitlePinned: film.SortTitlePinned, SortTitlePinned: film.SortTitlePinned,
PublishAt: film.PublishAt)); PublishAt: film.PublishAt,
ExternalRatings: await FremdwertungenZu(db, film: id, serie: null)));
}).RequireAuthorization(); }).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}", async ( app.MapGet("/api/v1/series/{id:guid}", async (
@@ -674,7 +690,8 @@ public static class LibraryContentEndpoints
TitlePinned: serie.TitlePinned, TitlePinned: serie.TitlePinned,
SortTitle: serie.SortTitleValue, SortTitle: serie.SortTitleValue,
SortTitlePinned: serie.SortTitlePinned, SortTitlePinned: serie.SortTitlePinned,
PublishAt: folgen.Min(f => f.PublishAt))); PublishAt: folgen.Min(f => f.PublishAt),
ExternalRatings: await FremdwertungenZu(db, film: null, serie: id)));
}).RequireAuthorization(); }).RequireAuthorization();
// **Eine Folge für sich.** // **Eine Folge für sich.**
@@ -17,7 +17,8 @@ public static class SettingsEndpoints
var zeilen = await db.Settings.AsNoTracking() var zeilen = await db.Settings.AsNoTracking()
.Where(s => s.Key == DatabaseTmdbSettings.ApiKeyName .Where(s => s.Key == DatabaseTmdbSettings.ApiKeyName
|| s.Key == DatabaseTmdbSettings.LanguageName || s.Key == DatabaseTmdbSettings.LanguageName
|| s.Key == DatabaseFanartSettings.ApiKeyName) || s.Key == DatabaseFanartSettings.ApiKeyName
|| s.Key == DatabaseOmdbSettings.ApiKeyName)
.ToDictionaryAsync(s => s.Key, s => s.Value); .ToDictionaryAsync(s => s.Key, s => s.Value);
// **Der Schlüssel selbst kommt nicht zurück.** Er gäbe jedem // **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. // benutzt wird — und dass ein eigener ihn ablösen würde.
UsingProjectKey: aufgelöst.UsingProjectKey, UsingProjectKey: aufgelöst.UsingProjectKey,
HasFanartKey: !string.IsNullOrWhiteSpace( HasFanartKey: !string.IsNullOrWhiteSpace(
zeilen.GetValueOrDefault(DatabaseFanartSettings.ApiKeyName)))); zeilen.GetValueOrDefault(DatabaseFanartSettings.ApiKeyName)),
HasOmdbKey: !string.IsNullOrWhiteSpace(
zeilen.GetValueOrDefault(DatabaseOmdbSettings.ApiKeyName))));
}).RequireAuthorization(FlaroPolicies.Administrator); }).RequireAuthorization(FlaroPolicies.Administrator);
// **In welcher Sprache der Bestand vorliegt.** // **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.ApiKey is not null) await Setzen(db, DatabaseTmdbSettings.ApiKeyName, anfrage.ApiKey.Trim());
if (anfrage.FanartApiKey is not null) if (anfrage.FanartApiKey is not null)
await Setzen(db, DatabaseFanartSettings.ApiKeyName, anfrage.FanartApiKey.Trim()); 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) if (anfrage.Language is not null)
{ {
var sprache = anfrage.Language.Trim(); 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(); 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); c.Timeout = TimeSpan.FromSeconds(20);
}); });
builder.Services.AddScoped<IBildquelle>(sp => sp.GetRequiredService<FanartClient>()); 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.AddSingleton(new ImageCacheOptions(pfade.Cache));
builder.Services.AddHttpClient<ImageCache>(c => c.Timeout = TimeSpan.FromSeconds(30)); builder.Services.AddHttpClient<ImageCache>(c => c.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddScoped<IImageStore>(sp => sp.GetRequiredService<ImageCache>()); builder.Services.AddScoped<IImageStore>(sp => sp.GetRequiredService<ImageCache>());
@@ -64,3 +64,36 @@ public sealed class Credit
/// </remarks> /// </remarks>
public int Order { get; set; } 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); 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> /// <summary>Wie Bilder nach Sprache geordnet werden.</summary>
/// ///
/// <remarks> /// <remarks>
+14
View File
@@ -75,6 +75,7 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
public DbSet<UserItemState> UserItemStates => Set<UserItemState>(); public DbSet<UserItemState> UserItemStates => Set<UserItemState>();
public DbSet<Genre> Genres => Set<Genre>(); public DbSet<Genre> Genres => Set<Genre>();
public DbSet<GenreLink> GenreLinks => Set<GenreLink>(); public DbSet<GenreLink> GenreLinks => Set<GenreLink>();
public DbSet<Fremdbewertung> Fremdbewertungen => Set<Fremdbewertung>();
public DbSet<Person> People => Set<Person>(); public DbSet<Person> People => Set<Person>();
public DbSet<Credit> Credits => Set<Credit>(); public DbSet<Credit> Credits => Set<Credit>();
public DbSet<ItemTranslation> Translations => Set<ItemTranslation>(); 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"); 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 => b.Entity<GenreLink>(e =>
{ {
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
+47
View File
@@ -30,6 +30,7 @@ public sealed class MetadataEnricher(
IImageStore bilder, IImageStore bilder,
IImageReader bildLeser, IImageReader bildLeser,
IBildquelle zweiteBildquelle, IBildquelle zweiteBildquelle,
IBewertungsquelle bewertungen,
ISidecarStore beiwerk, ISidecarStore beiwerk,
Zusammenlegung zusammenlegung, Zusammenlegung zusammenlegung,
TimeProvider uhr, TimeProvider uhr,
@@ -242,6 +243,7 @@ public sealed class MetadataEnricher(
film.ContentRating = gefunden.ContentRating; film.ContentRating = gefunden.ContentRating;
film.MatchLocked = festgelegt; film.MatchLocked = festgelegt;
await BewertungenSetzen(film.Id, null, gefunden.ImdbId, ct);
await GenresSetzen(gefunden.Genres, film: film.Id, serie: null, ct); await GenresSetzen(gefunden.Genres, film: film.Id, serie: null, ct);
await MitwirkendeSetzen(gefunden.Credits, film: film.Id, serie: null, ct); await MitwirkendeSetzen(gefunden.Credits, film: film.Id, serie: null, ct);
zugeordnet++; zugeordnet++;
@@ -399,6 +401,7 @@ public sealed class MetadataEnricher(
serie.Tagline = gefunden.Tagline; serie.Tagline = gefunden.Tagline;
serie.ContentRating = gefunden.ContentRating; serie.ContentRating = gefunden.ContentRating;
await BewertungenSetzen(null, serie.Id, gefunden.ImdbId, ct);
await GenresSetzen(gefunden.Genres, film: null, serie: serie.Id, ct); await GenresSetzen(gefunden.Genres, film: null, serie: serie.Id, ct);
await MitwirkendeSetzen(gefunden.Credits, 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); 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) => private async Task<string?> BildAblegen(RemoteImage? bild, CancellationToken ct) =>
bild is null ? null : await bilder.StoreAsync(bild, 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"); 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 => modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1004,6 +1042,19 @@ namespace Flaro.Data.Migrations
.IsRequired(); .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 => modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{ {
b.HasOne("Flaro.Core.Library.Genre", null) 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; 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 = { export type Film = {
id: string; title: string; year: number | null; overview: string | null; id: string; title: string; year: number | null; overview: string | null;
posterImage: string | null; backdropImage: string | null; files: Datei[]; posterImage: string | null; backdropImage: string | null; files: Datei[];
@@ -61,6 +64,8 @@ export type Film = {
logoImage: string | null; logoImage: string | null;
runtimeMinutes: number | null; runtimeMinutes: number | null;
rating: 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; tagline: string | null;
contentRating: string | null; contentRating: string | null;
/** Leer in Listen, gefüllt beim Einzelabruf. */ /** Leer in Listen, gefüllt beim Einzelabruf. */
@@ -86,6 +91,8 @@ export type Serie = {
logoImage: string | null; logoImage: string | null;
runtimeMinutes: number | null; runtimeMinutes: number | null;
rating: 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; tagline: string | null;
contentRating: string | null; contentRating: string | null;
genres: string[] | null; genres: string[] | null;
@@ -1264,9 +1271,13 @@ export const metadatenEinstellung = () =>
hasApiKey: boolean; language: string; usingProjectKey: boolean; hasApiKey: boolean; language: string; usingProjectKey: boolean;
/** Ob eine zweite Bildquelle (fanart.tv) eingerichtet ist. */ /** Ob eine zweite Bildquelle (fanart.tv) eingerichtet ist. */
hasFanartKey: boolean; hasFanartKey: boolean;
/** Ob eine Bewertungsquelle (OMDb) eingerichtet ist. */
hasOmdbKey: boolean;
}>('/settings/metadata'); }>('/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) }); anfrage<void>('/settings/metadata', { method: 'PUT', body: JSON.stringify(werte) });
/* ---------- Zuordnung ---------- */ /* ---------- 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 { display: flex; align-items: center; gap: 5px; color: var(--text); font-weight: 600; }
.bewertung svg { width: 15px; height: 15px; color: var(--accent); } .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); } .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); } .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, * Schriftzug des Films statt der Systemschrift, darunter Eckdaten,
* Bewertung, Knöpfe, Beschreibung, die Medienangaben als Tabelle und zuletzt * Bewertung, Knöpfe, Beschreibung, die Medienangaben als Tabelle und zuletzt
* die Besetzung mit runden Porträts. */ * 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 { export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElement {
const wurzel = document.createElement('div'); const wurzel = document.createElement('div');
wurzel.className = 'detail'; wurzel.className = 'detail';
@@ -238,6 +251,23 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
zeile.appendChild(b); 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; return zeile;
} }
+14 -1
View File
@@ -202,7 +202,8 @@ export function serverbereich(geaendert: () => void): HTMLElement {
(stand.usingProjectKey ? 'Mitgelieferter TMDB-Zugang' (stand.usingProjectKey ? 'Mitgelieferter TMDB-Zugang'
: stand.hasApiKey ? 'Eigener TMDB-Zugang' : stand.hasApiKey ? 'Eigener TMDB-Zugang'
: 'Kein Zugang hinterlegt') : '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 stand.usingProjectKey
? 'Ein eigener Zugang löst den mitgelieferten ab.' ? 'Ein eigener Zugang löst den mitgelieferten ab.'
: 'Nach einer Sprachänderung die Metadaten auffrischen — ' : '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 ' + 'für Filme — der Serienweg von fanart.tv braucht eine '
+ 'TVDB-Kennung, die Flaro nicht führt.', + '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', bestaetigen: 'Speichern',
tun: async wert => { tun: async wert => {
await metadatenSetzen({ await metadatenSetzen({
apiKey: wert('schluessel') || undefined, apiKey: wert('schluessel') || undefined,
fanartApiKey: wert('fanart') || undefined, fanartApiKey: wert('fanart') || undefined,
omdbApiKey: wert('omdb') || undefined,
}); });
location.reload(); location.reload();
}, },