From af2b844d65dde943a3b59c25965578ed0c0239e2 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 8 Oct 2026 21:26:58 +0200 Subject: [PATCH] IMDb, Rotten Tomatoes und Metacritic neben der TMDB-Wertung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/Flaro.Api/Endpoints/Contracts.cs | 33 +- .../Endpoints/LibraryContentEndpoints.cs | 21 +- .../Flaro.Api/Endpoints/SettingsEndpoints.cs | 9 +- server/src/Flaro.Api/MetadataSettings.cs | 26 + server/src/Flaro.Api/Program.cs | 12 + .../src/Flaro.Core/Library/CatalogEntities.cs | 33 + .../Flaro.Core/Metadata/IMetadataProvider.cs | 24 + server/src/Flaro.Data/FlaroContext.cs | 14 + server/src/Flaro.Data/MetadataEnricher.cs | 47 + ...0261008191902_Fremdbewertungen.Designer.cs | 1185 +++++++++++++++++ .../20261008191902_Fremdbewertungen.cs | 64 + .../Migrations/FlaroContextModelSnapshot.cs | 51 + server/src/Flaro.Metadata/OmdbClient.cs | 137 ++ .../tests/Flaro.Metadata.Tests/OmdbTests.cs | 149 +++ web/app/src/api.ts | 13 +- web/app/src/style.css | 16 + web/app/src/views/detail.ts | 30 + web/app/src/views/einstellungen/uebersicht.ts | 15 +- 18 files changed, 1869 insertions(+), 10 deletions(-) create mode 100644 server/src/Flaro.Data/Migrations/20261008191902_Fremdbewertungen.Designer.cs create mode 100644 server/src/Flaro.Data/Migrations/20261008191902_Fremdbewertungen.cs create mode 100644 server/src/Flaro.Metadata/OmdbClient.cs create mode 100644 server/tests/Flaro.Metadata.Tests/OmdbTests.cs diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 527ca3a..042ee30 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -436,7 +436,16 @@ public sealed record MovieResponse( string? SortTitle = null, /// Ob der Sortiertitel von Hand gesetzt wurde. - bool SortTitlePinned = false); + bool SortTitlePinned = false, + + /// Bewertungen von anderswo — leer, wenn keine Quelle eingerichtet ist. + /// + /// + /// Neben Rating, nicht statt dessen. Das Feld ist die + /// Wertung von TMDB und bleibt, wo es ist; ein Client, der nur das + /// kennt, zeigt weiter dasselbe wie bisher. + /// + IReadOnlyList? 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, /// Ob der Sortiertitel von Hand gesetzt wurde. - bool SortTitlePinned = false); + bool SortTitlePinned = false, + + /// Bewertungen von anderswo — leer, wenn keine Quelle eingerichtet ist. + /// + /// + /// Neben Rating, nicht statt dessen. Das Feld ist die + /// Wertung von TMDB und bleibt, wo es ist; ein Client, der nur das + /// kennt, zeigt weiter dasselbe wie bisher. + /// + IReadOnlyList? 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(IReadOnlyList Items, int Total); /// selbst nicht zurück — zum Anzeigen genügt, dass einer da ist. /// 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); /// Was ein Client über der Mediathek anzeigen muss. /// @@ -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); /// Was der Server über die Wiedergabe entscheidet. /// @@ -720,6 +739,12 @@ public sealed record StreamResponse( int? AudioStream, IReadOnlyList AudioStreams, IReadOnlyList SubtitleStreams); +/// Eine Bewertung von anderswo, fertig zum Hinschreiben. +/// +/// „imdb", „rottenTomatoes", „metacritic". +/// Was dastehen soll — in der Form, die man von der Quelle kennt. +public sealed record Fremdwertungsantwort(string Source, double Value, string Display); + public sealed record RatingRequest(int? Rating); /// Was ein Benutzer mit einem Titel zu tun hatte. diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs index 31da721..b9bccd0 100644 --- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs @@ -168,6 +168,21 @@ public static class LibraryContentEndpoints } /// Die Genres eines Titels, in der Reihenfolge der Quelle. + /// Die Bewertungen von anderswo zu einem Titel. + /// + /// + /// Nur beim Einzelabruf. In einer Liste mit fünfzig Kacheln + /// stünden sie nirgends, kosteten aber fünfzig Abfragen — dieselbe + /// Überlegung wie bei Genres und Besetzung. + /// + private static async Task> 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> 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.** diff --git a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs index 0baf1fb..6d01153 100644 --- a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs @@ -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(); diff --git a/server/src/Flaro.Api/MetadataSettings.cs b/server/src/Flaro.Api/MetadataSettings.cs index 90858c4..9f4d917 100644 --- a/server/src/Flaro.Api/MetadataSettings.cs +++ b/server/src/Flaro.Api/MetadataSettings.cs @@ -62,3 +62,29 @@ public sealed class DatabaseFanartSettings(FlaroContext db) : IFanartSettingsSou return _gemerkt = string.IsNullOrWhiteSpace(zeile?.Value) ? null : zeile.Value.Trim(); } } + +/// Der Schlüssel für OMDb, aus der Datenbank. +/// +/// +/// Wie bei fanart.tv: kein mitgelieferter. OMDb begrenzt je Schlüssel auf +/// tausend Abrufe am Tag — einen fremden mitzuliefern hiesse, dessen +/// Kontingent zu verbrauchen. +/// +public sealed class DatabaseOmdbSettings(FlaroContext db) : IOmdbSettingsSource +{ + public const string ApiKeyName = "metadata.omdb.apiKey"; + + private string? _gemerkt; + private bool _gelesen; + + public async Task 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(); + } +} diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index 7167a4e..e098955 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -111,6 +111,18 @@ builder.Services.AddHttpClient(c => c.Timeout = TimeSpan.FromSeconds(20); }); builder.Services.AddScoped(sp => sp.GetRequiredService()); + +// **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(); +builder.Services.AddHttpClient(c => +{ + c.BaseAddress = new Uri(OmdbClient.BaseAddress); + c.Timeout = TimeSpan.FromSeconds(20); +}); +builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddSingleton(new ImageCacheOptions(pfade.Cache)); builder.Services.AddHttpClient(c => c.Timeout = TimeSpan.FromSeconds(30)); builder.Services.AddScoped(sp => sp.GetRequiredService()); diff --git a/server/src/Flaro.Core/Library/CatalogEntities.cs b/server/src/Flaro.Core/Library/CatalogEntities.cs index ff1da85..d8228da 100644 --- a/server/src/Flaro.Core/Library/CatalogEntities.cs +++ b/server/src/Flaro.Core/Library/CatalogEntities.cs @@ -64,3 +64,36 @@ public sealed class Credit /// public int Order { get; set; } } + +/// Eine Bewertung von anderswo — IMDb, Rotten Tomatoes, Metacritic. +/// +/// +/// +/// 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. So ist jede Quelle eine Zeile. +/// +/// +/// Zwei Werte, und beide gebraucht. ist auf 0 bis +/// 10 gebracht, damit sich Quellen vergleichen und sortieren lassen; +/// 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. +/// +/// +public sealed class Fremdbewertung +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); + public Guid? MovieId { get; init; } + public Guid? SeriesId { get; init; } + + /// „imdb", „rottenTomatoes" oder „metacritic". + public required string Quelle { get; set; } + + /// Auf 0 bis 10 gebracht — zum Vergleichen, nicht zum Anzeigen. + public double Wert { get; set; } + + /// Was dastehen soll: „7,4", „87 %", „62". + public required string Anzeige { get; set; } +} diff --git a/server/src/Flaro.Core/Metadata/IMetadataProvider.cs b/server/src/Flaro.Core/Metadata/IMetadataProvider.cs index 0154bfd..aa02499 100644 --- a/server/src/Flaro.Core/Metadata/IMetadataProvider.cs +++ b/server/src/Flaro.Core/Metadata/IMetadataProvider.cs @@ -280,6 +280,30 @@ public interface IBildquelle Task> FilmbilderAsync(int tmdbId, CancellationToken ct = default); } +/// Eine Bewertung von anderswo. +/// +/// „imdb", „rottenTomatoes", „metacritic". +/// Auf 0 bis 10 gebracht — zum Vergleichen. +/// Was dastehen soll: „7,4", „87 %", „62". +public sealed record Fremdwertung(string Quelle, double Wert, string Anzeige); + +/// Eine Quelle, die nur Bewertungen liefert. +/// +/// +/// Wieder nicht . Aus demselben Grund +/// wie bei : 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. +/// +public interface IBewertungsquelle +{ + Task EingerichtetAsync(CancellationToken ct = default); + + /// Alle Bewertungen zu einer IMDb-Kennung. + Task> HolenAsync(string imdbId, CancellationToken ct = default); +} + /// Wie Bilder nach Sprache geordnet werden. /// /// diff --git a/server/src/Flaro.Data/FlaroContext.cs b/server/src/Flaro.Data/FlaroContext.cs index 0d46b7e..619507a 100644 --- a/server/src/Flaro.Data/FlaroContext.cs +++ b/server/src/Flaro.Data/FlaroContext.cs @@ -75,6 +75,7 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon public DbSet UserItemStates => Set(); public DbSet Genres => Set(); public DbSet GenreLinks => Set(); + public DbSet Fremdbewertungen => Set(); public DbSet People => Set(); public DbSet Credits => Set(); public DbSet Translations => Set(); @@ -271,6 +272,19 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon e.Property(x => x.Name).HasMaxLength(64).UseCollation("NOCASE"); }); + b.Entity(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().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade); + e.HasOne().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade); + e.Property(x => x.Quelle).HasMaxLength(32); + e.Property(x => x.Anzeige).HasMaxLength(32); + }); + b.Entity(e => { e.HasKey(x => x.Id); diff --git a/server/src/Flaro.Data/MetadataEnricher.cs b/server/src/Flaro.Data/MetadataEnricher.cs index 7aa85fb..e7a66fc 100644 --- a/server/src/Flaro.Data/MetadataEnricher.cs +++ b/server/src/Flaro.Data/MetadataEnricher.cs @@ -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); } + /// Holt die Bewertungen von anderswo und schreibt sie um. + /// + /// + /// Umschreiben, nicht anhäufen. 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. + /// + /// Ohne IMDb-Kennung geht gar nichts. OMDb kennt nur diesen Weg; + /// einen Titel zu suchen hiesse, denselben Zuordnungsfehler ein zweites + /// Mal zu riskieren. + /// + 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 BildAblegen(RemoteImage? bild, CancellationToken ct) => bild is null ? null : await bilder.StoreAsync(bild, ct); diff --git a/server/src/Flaro.Data/Migrations/20261008191902_Fremdbewertungen.Designer.cs b/server/src/Flaro.Data/Migrations/20261008191902_Fremdbewertungen.Designer.cs new file mode 100644 index 0000000..0def74f --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261008191902_Fremdbewertungen.Designer.cs @@ -0,0 +1,1185 @@ +// +using System; +using Flaro.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + [DbContext(typeof(FlaroContext))] + [Migration("20261008191902_Fremdbewertungen")] + partial class Fremdbewertungen + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ConsumedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("HandleFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("HandleFingerprint") + .IsUnique(); + + b.ToTable("AuthCodes"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.Einladung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("MaxUses") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UsedCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.ToTable("Einladungen"); + }); + + modelBuilder.Entity("Flaro.Core.Libraries.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ReviewNewItems") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Libraries"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Character") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("PersonId"); + + b.HasIndex("SeriesId"); + + b.ToTable("Credits"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("StillImage") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber") + .IsUnique(); + + b.ToTable("Episodes"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Fremdbewertung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Anzeige") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Quelle") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("GenreId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("GenreId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("GenreLinks"); + }); + + modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("INTEGER"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId", "Language") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("MovieId", "Language") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("SeriesId", "Language") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("Translations"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("Container") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("MissingSince") + .HasColumnType("INTEGER"); + + b.Property("ModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ProbedAt") + .HasColumnType("INTEGER"); + + b.Property("SeenAt") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("VersionLabel") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("LibraryId", "Path") + .IsUnique(); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MediaFileId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId", "Index") + .IsUnique(); + + b.ToTable("MediaStreams"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Meldung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Art") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("ErledigtAm") + .HasColumnType("INTEGER"); + + b.Property("ErledigtVon") + .HasColumnType("TEXT"); + + b.Property("GemeldetAm") + .HasColumnType("INTEGER"); + + b.Property("Kommentar") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Stelle") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("ErledigtAm", "GemeldetAm"); + + b.HasIndex("UserId", "GemeldetAm"); + + b.ToTable("Meldungen"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ChangedSince") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("FassungenGeprueftBei") + .HasColumnType("INTEGER"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitlePinned") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Image") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("People"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ChangedSince") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitlePinned") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.Sehtag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Sekunden") + .HasColumnType("REAL"); + + b.Property("Tag") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("UserId", "Tag"); + + b.HasIndex("UserId", "Tag", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "Tag", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.ToTable("Sehtage"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Dismissed") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("Favourite") + .HasColumnType("INTEGER"); + + b.Property("InProgress") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Watched") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("UserId", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("UserId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.HasIndex("UserId", "InProgress", "UpdatedAt"); + + b.ToTable("UserItemStates"); + }); + + modelBuilder.Entity("Flaro.Core.ServerSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Flaro.Core.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarImage") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("IsAdministrator") + .HasColumnType("INTEGER"); + + b.Property("IsOwner") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Auth.Einladung", b => + { + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Person", null) + .WithMany() + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .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) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.MediaFile", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.HasOne("Flaro.Core.Library.MediaFile", null) + .WithMany() + .HasForeignKey("MediaFileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Meldung", b => + { + b.HasOne("Flaro.Core.Library.Episode", null) + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + 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); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Playback.Sehtag", b => + { + b.HasOne("Flaro.Core.Library.Episode", null) + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.HasOne("Flaro.Core.Library.Episode", null) + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + 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); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Flaro.Data/Migrations/20261008191902_Fremdbewertungen.cs b/server/src/Flaro.Data/Migrations/20261008191902_Fremdbewertungen.cs new file mode 100644 index 0000000..5444df8 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261008191902_Fremdbewertungen.cs @@ -0,0 +1,64 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class Fremdbewertungen : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Fremdbewertungen", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + MovieId = table.Column(type: "TEXT", nullable: true), + SeriesId = table.Column(type: "TEXT", nullable: true), + Quelle = table.Column(type: "TEXT", maxLength: 32, nullable: false), + Wert = table.Column(type: "REAL", nullable: false), + Anzeige = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Fremdbewertungen"); + } + } +} diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs index d7b71e2..348fa4d 100644 --- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs +++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs @@ -265,6 +265,44 @@ namespace Flaro.Data.Migrations b.ToTable("Episodes"); }); + modelBuilder.Entity("Flaro.Core.Library.Fremdbewertung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Anzeige") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Quelle") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("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("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) diff --git a/server/src/Flaro.Metadata/OmdbClient.cs b/server/src/Flaro.Metadata/OmdbClient.cs new file mode 100644 index 0000000..d400c88 --- /dev/null +++ b/server/src/Flaro.Metadata/OmdbClient.cs @@ -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; + +/// Woher der Schlüssel für OMDb kommt. +public interface IOmdbSettingsSource +{ + Task SchluesselAsync(CancellationToken ct = default); +} + +/// Bewertungen von IMDb, Rotten Tomatoes und Metacritic. +/// +/// +/// +/// Warum OMDb und nicht die Quellen selbst. 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. +/// +/// +/// Zwei Werte je Bewertung, und beide gebraucht. 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. +/// +/// +/// Ohne Schlüssel geschieht nichts. OMDb verlangt einen +/// persönlichen, kostenlosen; einen fremden mitzuliefern hiesse, dessen +/// Tageskontingent zu verbrauchen. +/// +/// +public sealed class OmdbClient( + HttpClient http, + IOmdbSettingsSource einstellungen, + ILogger log) : IBewertungsquelle +{ + public const string BaseAddress = "https://www.omdbapi.com/"; + + public async Task EingerichtetAsync(CancellationToken ct = default) => + !string.IsNullOrWhiteSpace(await einstellungen.SchluesselAsync(ct)); + + public async Task> 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(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(); + + 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; + } + + /// Die Namen, die OMDb benutzt, auf unsere Kennungen. + 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, + }; + + /// Bringt „8.7/10", „87%" und „62/100" auf dieselbe Skala. + /// + /// + /// Nur zum Vergleichen. Angezeigt wird, was die Quelle sagt — siehe + /// . + /// + 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, + }; + } + + /// Was dastehen soll — in der Form, die man von der Quelle kennt. + 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? Ratings); + + private sealed record OmdbWertung( + [property: JsonPropertyName("Source")] string? Source, + [property: JsonPropertyName("Value")] string? Value); +} diff --git a/server/tests/Flaro.Metadata.Tests/OmdbTests.cs b/server/tests/Flaro.Metadata.Tests/OmdbTests.cs new file mode 100644 index 0000000..d7335d1 --- /dev/null +++ b/server/tests/Flaro.Metadata.Tests/OmdbTests.cs @@ -0,0 +1,149 @@ +using System.Net; +using Flaro.Core.Metadata; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Flaro.Metadata.Tests; + +/// Bewertungen von IMDb, Rotten Tomatoes und Metacritic. +/// +/// +/// Warum OMDb und nicht die Quellen selbst. 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. +/// +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.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); + }); + } + + /// Jede Quelle kommt mit zwei Werten: einem zum Vergleichen, einem zum Anzeigen. + /// + /// + /// 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. Zum + /// Sortieren braucht es trotzdem eine gemeinsame Skala — deshalb beides. + /// + [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")); + }); + } + + /// OMDb antwortet auf Unbekanntes mit 200 und „Response: False". + /// + /// + /// Das als Treffer durchzulassen hiesse, eine leere Bewertung abzulegen + /// — und die stünde danach als „keine Wertung" da, obwohl es nur keine + /// Antwort gab. + /// + [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); + } + + /// Eine unbekannte vierte Quelle wird übergangen, nicht geraten. + [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")); + } + + /// Ohne IMDb-Kennung wird gar nicht erst gefragt. + /// + /// + /// OMDb kennt nur diesen Weg; nach einem Titel zu suchen hiesse, denselben + /// Zuordnungsfehler ein zweites Mal zu riskieren. + /// + [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 SchluesselAsync(CancellationToken ct = default) => Task.FromResult(Wert); +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 14a2c2c..a365f41 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -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('/settings/metadata', { method: 'PUT', body: JSON.stringify(werte) }); /* ---------- Zuordnung ---------- */ diff --git a/web/app/src/style.css b/web/app/src/style.css index f1dca6b..3b87068 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -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); } diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts index e96bb30..182f6de 100644 --- a/web/app/src/views/detail.ts +++ b/web/app/src/views/detail.ts @@ -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 = { + imdb: 'IMDb', rottenTomatoes: 'RT', metacritic: 'MC', +}; + +const QUELLENNAMEN: Record = { + 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; } diff --git a/web/app/src/views/einstellungen/uebersicht.ts b/web/app/src/views/einstellungen/uebersicht.ts index 435f7e7..54effa4 100644 --- a/web/app/src/views/einstellungen/uebersicht.ts +++ b/web/app/src/views/einstellungen/uebersicht.ts @@ -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(); },