From 97edb495643d6e702e45d4e0decab64fcc132ae8 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 8 Oct 2026 21:18:27 +0200 Subject: [PATCH] Eine zweite Bildquelle, weil TMDB nicht alles hat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit „365 Tage" stand mit polnischem Plakat da. Nachgesehen: TMDB hat zu diesem Film 26 Plakate und kein einziges deutsches. Die Auswahl von gestern nimmt seither das sprachlose statt des polnischen — erfinden kann sie keines. fanart.tv ist die Quelle, bei der jedes Plakat seine Sprache mitführt, und die Gemeinschaft dort pflegt landessprachliche Fassungen. OMDb wäre keine Hilfe gewesen: es liefert das Plakat von IMDb, und das ist in aller Regel das amerikanische. **Ausdrücklich keine zweite Metadatenquelle.** `IBildquelle` liefert Bilder, sonst nichts. Titel, Beschreibung und Besetzung kommen weiterhin von einer Stelle — zwei Anbieter für dieselbe Angabe wären zwei Wahrheiten. **Gefragt wird nur, wenn es lohnt.** `MovieMetadata` trägt jetzt die Sprache des gewählten Plakats mit. Hat TMDB schon eines in der eingestellten Sprache, geht keine Anfrage hinaus — bei achthundert Titeln ist das der Unterschied zwischen „läuft nebenher" und „hängt am fremden Dienst". Und nur ein landessprachliches sticht: ein sprachloses von fanart.tv ist nicht besser als ein sprachloses von TMDB. **Die Rangfolge liegt jetzt im Kern**, nicht im TMDB-Client. Sie gilt für alle Quellen, sonst kämen die Bilder zweier Anbieter in zwei Reihenfolgen zusammen. Im Bilderdialog stehen beide Quellen gemeinsam geordnet: ein deutsches Plakat von fanart.tv vor einem sprachlosen von TMDB. **Nur Filme.** Der Serienweg von fanart.tv verlangt eine TVDB-Kennung, und die führt Flaro nicht. Eine halbe Lösung, die bei Serien stillschweigend nichts tut, wäre schlechter als eine, die sagt, worauf sie sich beschränkt. **Kein mitgelieferter Schlüssel.** Bei TMDB gibt es einen als Rückfall; fanart.tv verlangt einen persönlichen, und einen fremden mitzuliefern hiesse, ihn fremd zu benutzen. Ohne eigenen Schlüssel geht keine Anfrage hinaus — geprüft. Einzutragen unter Einstellungen → Übersicht → Metadaten. Dass „00" dort „ohne Sprache" heisst, wird beim Lesen übersetzt: sonst führte die gemeinsame Rangfolge ein sprachloses Plakat als Sprache „00" und setzte es hinter Englisch statt davor. 732 Tests grün, davon sieben neue. Co-Authored-By: Claude Opus 5 --- .../Flaro.Api/Endpoints/ArtworkEndpoints.cs | 29 +++- server/src/Flaro.Api/Endpoints/Contracts.cs | 9 +- .../Flaro.Api/Endpoints/SettingsEndpoints.cs | 10 +- server/src/Flaro.Api/MetadataSettings.cs | 27 +++ server/src/Flaro.Api/Program.cs | 13 ++ .../Flaro.Core/Metadata/IMetadataProvider.cs | 71 +++++++- server/src/Flaro.Data/MetadataEnricher.cs | 52 +++++- server/src/Flaro.Metadata/FanartClient.cs | 159 ++++++++++++++++++ server/src/Flaro.Metadata/TmdbClient.cs | 37 ++-- .../Flaro.Api.Tests/WartungsplanTests.cs | 2 +- .../tests/Flaro.Metadata.Tests/FanartTests.cs | 137 +++++++++++++++ web/app/src/api.ts | 8 +- web/app/src/views/einstellungen/uebersicht.ts | 23 ++- 13 files changed, 545 insertions(+), 32 deletions(-) create mode 100644 server/src/Flaro.Metadata/FanartClient.cs create mode 100644 server/tests/Flaro.Metadata.Tests/FanartTests.cs diff --git a/server/src/Flaro.Api/Endpoints/ArtworkEndpoints.cs b/server/src/Flaro.Api/Endpoints/ArtworkEndpoints.cs index cbe1be5..17bba2b 100644 --- a/server/src/Flaro.Api/Endpoints/ArtworkEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ArtworkEndpoints.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Flaro.Core.Metadata; using Flaro.Data; using Flaro.Metadata; @@ -34,7 +35,8 @@ public static class ArtworkEndpoints .RequireAuthorization(FlaroPolicies.Administrator); gruppe.MapGet("/{art:regex(^movies|series$)}/{id:guid}/artwork", async ( - string art, Guid id, FlaroContext db, TmdbClient tmdb, CancellationToken ct) => + string art, Guid id, FlaroContext db, TmdbClient tmdb, FanartClient fanart, + ITmdbSettingsSource spracheVon, CancellationToken ct) => { var serie = art == "series"; var kennung = serie @@ -53,9 +55,28 @@ public static class ArtworkEndpoints // Ohne Zuordnung gibt es nichts zu wählen — dann ist die // Zuordnung das Problem, nicht das Bild. - var kandidaten = kennung is { } k - ? await tmdb.BilderAsync(k, serie, ct) - : []; + IReadOnlyList kandidaten = []; + if (kennung is { } k) + { + var ausTmdb = await tmdb.BilderAsync(k, serie, ct); + + // **fanart.tv nur bei Filmen.** Deren Serienweg verlangt eine + // TVDB-Kennung, und die speichert Flaro nicht. Lieber eine + // Beschränkung, die man sieht, als eine, die stillschweigend + // nichts tut. + var ausFanart = serie + ? [] + : await fanart.BilderAsync(k.ToString(CultureInfo.InvariantCulture), ct); + + var sprache = (await spracheVon.GetAsync(ct)).Language; + var kuerzel = sprache.Split('-')[0]; + + // **Gemeinsam geordnet, nicht hintereinandergehängt.** Ein + // deutsches Plakat von fanart.tv gehört vor ein sprachloses + // von TMDB — das ist der Grund, warum es die zweite Quelle + // überhaupt gibt. + kandidaten = Bildrang.Geordnet(ausTmdb.Concat(ausFanart), kuerzel).ToList(); + } return Results.Ok(new Bildauswahl(aktuell, kandidaten)); }); diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index b8fea03..527ca3a 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -519,8 +519,12 @@ public sealed record EpisodeResponse( public sealed record Page(IReadOnlyList Items, int Total); +/// +/// Ob eine zweite Bildquelle eingerichtet ist. Auch hier kommt der Schlüssel +/// selbst nicht zurück — zum Anzeigen genügt, dass einer da ist. +/// public sealed record MetadataSettingsResponse( - bool HasApiKey, string Language, bool UsingProjectKey); + bool HasApiKey, string Language, bool UsingProjectKey, bool HasFanartKey = false); /// Was ein Client über der Mediathek anzeigen muss. /// @@ -532,7 +536,8 @@ public sealed record MetadataSettingsResponse( /// public sealed record AttributionResponse(string Text, string LogoUrl, string Url); -public sealed record MetadataSettingsRequest(string? ApiKey, string? Language); +public sealed record MetadataSettingsRequest( + string? ApiKey, string? Language, string? FanartApiKey = null); /// Was der Server über die Wiedergabe entscheidet. /// diff --git a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs index a11a030..0baf1fb 100644 --- a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs @@ -15,7 +15,9 @@ public static class SettingsEndpoints app.MapGet("/api/v1/settings/metadata", async (FlaroContext db) => { var zeilen = await db.Settings.AsNoTracking() - .Where(s => s.Key == DatabaseTmdbSettings.ApiKeyName || s.Key == DatabaseTmdbSettings.LanguageName) + .Where(s => s.Key == DatabaseTmdbSettings.ApiKeyName + || s.Key == DatabaseTmdbSettings.LanguageName + || s.Key == DatabaseFanartSettings.ApiKeyName) .ToDictionaryAsync(s => s.Key, s => s.Value); // **Der Schlüssel selbst kommt nicht zurück.** Er gäbe jedem @@ -30,7 +32,9 @@ public static class SettingsEndpoints Language: aufgelöst.Language, // Damit in der Oberfläche steht, ob der mitgelieferte Schlüssel // benutzt wird — und dass ein eigener ihn ablösen würde. - UsingProjectKey: aufgelöst.UsingProjectKey)); + UsingProjectKey: aufgelöst.UsingProjectKey, + HasFanartKey: !string.IsNullOrWhiteSpace( + zeilen.GetValueOrDefault(DatabaseFanartSettings.ApiKeyName)))); }).RequireAuthorization(FlaroPolicies.Administrator); // **In welcher Sprache der Bestand vorliegt.** @@ -88,6 +92,8 @@ public static class SettingsEndpoints MetadataSettingsRequest anfrage, FlaroContext db) => { 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.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 6b1a615..90858c4 100644 --- a/server/src/Flaro.Api/MetadataSettings.cs +++ b/server/src/Flaro.Api/MetadataSettings.cs @@ -35,3 +35,30 @@ public sealed class DatabaseTmdbSettings(FlaroContext db) : ITmdbSettingsSource zeilen.GetValueOrDefault(LanguageName)); } } + +/// Der Schlüssel für fanart.tv, aus der Datenbank. +/// +/// +/// Kein mitgelieferter. Bei TMDB gibt es einen Projektschlüssel als +/// Rückfall; fanart.tv verlangt einen persönlichen, und einen fremden +/// mitzuliefern hiesse, ihn fremd zu benutzen. Ohne eigenen Schlüssel ist +/// die Quelle schlicht aus. +/// +public sealed class DatabaseFanartSettings(FlaroContext db) : IFanartSettingsSource +{ + public const string ApiKeyName = "metadata.fanart.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 e95f655..7167a4e 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -98,6 +98,19 @@ builder.Services.AddHttpClient(c => c.Timeout = TimeSpan.FromSeconds(20); }); builder.Services.AddScoped(sp => sp.GetRequiredService()); + +// **Eine zweite Bildquelle, und ausdrücklich keine zweite Metadatenquelle.** +// fanart.tv liefert Plakate mit Sprachangabe — genau das, was TMDB für +// manche Filme nicht hat. Titel, Beschreibung und Besetzung kommen +// weiterhin von einer Stelle; zwei Anbieter für dieselbe Angabe wären zwei +// Wahrheiten. +builder.Services.AddScoped(); +builder.Services.AddHttpClient(c => +{ + c.BaseAddress = new Uri(FanartClient.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/Metadata/IMetadataProvider.cs b/server/src/Flaro.Core/Metadata/IMetadataProvider.cs index f055fdf..0154bfd 100644 --- a/server/src/Flaro.Core/Metadata/IMetadataProvider.cs +++ b/server/src/Flaro.Core/Metadata/IMetadataProvider.cs @@ -28,6 +28,15 @@ public enum CreditKind { Actor, Director, Writer } public sealed record MovieMetadata( int ProviderId, string Title, int? Year, string? Overview, RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null, + /// In welcher Sprache das gewählte Plakat ist — null heisst sprachlos. + /// + /// + /// Damit eine zweite Quelle nur dann gefragt wird, wenn sie lohnt. + /// Hat TMDB schon ein deutsches Plakat, braucht niemand fanart.tv — und + /// bei achthundert Titeln sind achthundert vermiedene Anfragen der + /// Unterschied zwischen „läuft nebenher" und „hängt am fremden Dienst". + /// + string? PosterLanguage = null, /// Laufzeit in Minuten, wie die Quelle sie nennt. /// /// @@ -248,5 +257,65 @@ public interface IImageReader /// Mit Vorschau und Sprache. Wer ein Plakat aussucht, sucht meist eins /// in seiner Sprache, und er will es sehen, bevor er es nimmt — nicht danach. /// +/// Woher es stammt — „tmdb" oder „fanart". public sealed record Bildkandidat( - string Kind, string Url, string ThumbUrl, string? Language, double? Vote); + string Kind, string Url, string ThumbUrl, string? Language, double? Vote, + string Quelle = "tmdb"); + +/// Eine Quelle, die nur Bilder liefert — keine Metadaten. +/// +/// +/// Bewusst nicht . Dessen Methoden +/// liefern ganze Titelangaben; zwei Anbieter für Titel und Beschreibung +/// wären zwei Wahrheiten. Hier geht es nur um Plakate, und dort ist eine +/// zweite Quelle genau richtig: TMDB hat für manche Filme kein deutsches, +/// fanart.tv schon. +/// +public interface IBildquelle +{ + /// Ob sie überhaupt eingerichtet ist. + Task EingerichtetAsync(CancellationToken ct = default); + + /// Alle Bilder zu einem Film, mit Sprachangabe. + Task> FilmbilderAsync(int tmdbId, CancellationToken ct = default); +} + +/// Wie Bilder nach Sprache geordnet werden. +/// +/// +/// +/// Eine Rangfolge für alle Quellen. Eingestellte Sprache, dann +/// sprachlos, dann Englisch, dann der Rest. Sie stand erst nur im +/// TMDB-Client; seit es eine zweite Bildquelle gibt, muss sie an einer +/// Stelle liegen, sonst kämen die Bilder zweier Quellen in zwei +/// Reihenfolgen zusammen. +/// +/// +/// Sprachlos vor Englisch. Ein Bild ohne Sprachkennung trägt keinen +/// Text — ein Plakat ohne Titel, ein Hintergrund ohne eingebrannten +/// Schriftzug. Für einen deutschen Bestand ist das besser als dasselbe mit +/// englischem Titel darüber. +/// +/// +public static class Bildrang +{ + public static int Nach(string? sprache, string gewuenscht) => + string.Equals(sprache, gewuenscht, StringComparison.OrdinalIgnoreCase) ? 0 + : string.IsNullOrEmpty(sprache) ? 1 + : string.Equals(sprache, "en", StringComparison.OrdinalIgnoreCase) ? 2 + : 3; + + /// Ordnet Kandidaten: erst die Sprache, dann die Stimmen. + /// + /// + /// Die Quelle entscheidet nicht mit. Ein deutsches Plakat von + /// fanart.tv steht vor einem sprachlosen von TMDB — das ist der Grund, + /// warum es die zweite Quelle überhaupt gibt. Bei gleichem Rang gewinnt, + /// was mehr Zustimmung hat. + /// + public static IEnumerable Geordnet( + IEnumerable kandidaten, string sprache) => + kandidaten + .OrderBy(b => Nach(b.Language, sprache)) + .ThenByDescending(b => b.Vote ?? 0); +} diff --git a/server/src/Flaro.Data/MetadataEnricher.cs b/server/src/Flaro.Data/MetadataEnricher.cs index e0bbb6f..7aa85fb 100644 --- a/server/src/Flaro.Data/MetadataEnricher.cs +++ b/server/src/Flaro.Data/MetadataEnricher.cs @@ -29,6 +29,7 @@ public sealed class MetadataEnricher( IMetadataProvider anbieter, IImageStore bilder, IImageReader bildLeser, + IBildquelle zweiteBildquelle, ISidecarStore beiwerk, Zusammenlegung zusammenlegung, TimeProvider uhr, @@ -229,7 +230,10 @@ public sealed class MetadataEnricher( // **Was von Hand gewählt wurde, bleibt.** Ohne diese Prüfung // überschreibt jedes Auffrischen die Auswahl wieder — und wer // einmal ein besseres Plakat gesucht hat, sucht es jedes Mal neu. - if (!film.PosterPinned) film.PosterImage = await BildAblegen(gefunden.Poster, ct); + if (!film.PosterPinned) + { + film.PosterImage = await BildAblegen(await BestesPlakat(gefunden, ct), ct); + } if (!film.BackdropPinned) film.BackdropImage = await BildAblegen(gefunden.Backdrop, ct); if (!film.LogoPinned) film.LogoImage = await BildAblegen(gefunden.Logo, ct); film.RuntimeMinutes = gefunden.RuntimeMinutes; @@ -660,6 +664,52 @@ public sealed class MetadataEnricher( } } + /// Das Plakat, das am besten zur eingestellten Sprache passt. + /// + /// + /// + /// Die zweite Quelle wird nur gefragt, wenn sie lohnt. Hat TMDB + /// schon eines in der eingestellten Sprache, ist nichts zu holen — und + /// bei achthundert Titeln sind achthundert vermiedene Anfragen der + /// Unterschied zwischen „läuft nebenher" und „hängt am fremden Dienst". + /// + /// + /// Gemessen an „365 Tage": TMDB hat dort 26 Plakate und kein + /// deutsches. Die Auswahl im TMDB-Client nimmt seither das sprachlose + /// statt des polnischen — erfinden kann sie ein deutsches nicht. Genau + /// dafür gibt es fanart.tv, wo jedes Plakat seine Sprache mitführt. + /// + /// + /// Und nur ein landessprachliches sticht. Ein sprachloses von + /// fanart.tv ist nicht besser als ein sprachloses von TMDB; dafür eine + /// zweite Quelle zu befragen wäre Aufwand ohne Gewinn. + /// + /// + private async Task BestesPlakat(MovieMetadata gefunden, CancellationToken ct) + { + var gewuenscht = (gefunden.Language ?? "").Split('-')[0]; + if (gewuenscht.Length == 0) return gefunden.Poster; + + // Schon in der Sprache — oder gar keine Liste, dann weiss niemand + // etwas Besseres. + if (string.Equals(gefunden.PosterLanguage, gewuenscht, StringComparison.OrdinalIgnoreCase)) + return gefunden.Poster; + + if (!await zweiteBildquelle.EingerichtetAsync(ct)) return gefunden.Poster; + + var kandidaten = await zweiteBildquelle.FilmbilderAsync(gefunden.ProviderId, ct); + var treffer = kandidaten + .Where(b => b.Kind == "poster") + .Where(b => string.Equals(b.Language, gewuenscht, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(b => b.Vote ?? 0) + .FirstOrDefault(); + + if (treffer is null) return gefunden.Poster; + + log.LogDebug("Plakat für {Titel} kommt aus der zweiten Quelle.", gefunden.Title); + return new RemoteImage(treffer.Url); + } + private async Task BildAblegen(RemoteImage? bild, CancellationToken ct) => bild is null ? null : await bilder.StoreAsync(bild, ct); diff --git a/server/src/Flaro.Metadata/FanartClient.cs b/server/src/Flaro.Metadata/FanartClient.cs new file mode 100644 index 0000000..4365403 --- /dev/null +++ b/server/src/Flaro.Metadata/FanartClient.cs @@ -0,0 +1,159 @@ +using System.Net; +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 fanart.tv kommt. +public interface IFanartSettingsSource +{ + Task SchluesselAsync(CancellationToken ct = default); +} + +/// Plakate von fanart.tv — die Quelle mit Sprachangabe. +/// +/// +/// +/// Warum es diese zweite Quelle gibt. „365 Tage" stand mit polnischem +/// Plakat da. Nachgesehen: TMDB hat 26 Plakate zu diesem Film und kein +/// einziges deutsches. Die Auswahl in nimmt +/// seither das sprachneutrale statt des polnischen — aber ein deutsches +/// kann sie nicht erfinden. +/// +/// +/// fanart.tv ist die Quelle, bei der die Sprache mitsteht. Jedes +/// Plakat trägt ein lang, und die Gemeinschaft dort pflegt +/// landessprachliche Fassungen. OMDb wäre keine Hilfe gewesen: es liefert +/// das Plakat von IMDb, und das ist in aller Regel das amerikanische. +/// +/// +/// Nur Filme. Der Serienweg von fanart.tv verlangt eine TVDB-Kennung, +/// und die speichert Flaro nicht. Eine halbe Lösung, die bei Serien +/// stillschweigend nichts tut, wäre schlechter als eine, die sagt, worauf +/// sie sich beschränkt. +/// +/// +/// Ohne Schlüssel geschieht nichts. fanart.tv verlangt einen +/// persönlichen, kostenlosen Schlüssel — es gibt keinen mitgelieferten wie +/// bei TMDB. Fehlt er, geht keine Anfrage hinaus. +/// +/// +public sealed class FanartClient( + HttpClient http, + IFanartSettingsSource einstellungen, + ILogger log) : IBildquelle +{ + public const string BaseAddress = "https://webservice.fanart.tv/v3/"; + + /// Wie gross ein Plakat von dort ist — fanart.tv liefert eine Grösse. + /// + /// + /// Anders als TMDB gibt es keine Grössenstufen in der Adresse: was + /// dasteht, ist das Original. Für die Vorschau im Dialog reicht das; der + /// Bildspeicher legt es ohnehin so ab, wie es kommt. + /// + public async Task EingerichtetAsync(CancellationToken ct = default) => + !string.IsNullOrWhiteSpace(await einstellungen.SchluesselAsync(ct)); + + /// Alle Plakate und Hintergründe zu einem Film. + /// + /// Die TMDB-Kennung oder die IMDb-Kennung; fanart.tv nimmt beide. + public Task> FilmbilderAsync(int tmdbId, CancellationToken ct = default) => + BilderAsync(tmdbId.ToString(System.Globalization.CultureInfo.InvariantCulture), ct); + + public async Task> BilderAsync( + string kennung, CancellationToken ct = default) + { + var schluessel = await einstellungen.SchluesselAsync(ct); + if (string.IsNullOrWhiteSpace(schluessel) || string.IsNullOrWhiteSpace(kennung)) return []; + + FanartFilm? antwort; + try + { + using var nachricht = new HttpRequestMessage( + HttpMethod.Get, + $"movies/{Uri.EscapeDataString(kennung)}?api_key={Uri.EscapeDataString(schluessel)}"); + + using var ergebnis = await http.SendAsync(nachricht, ct); + + // **404 ist kein Fehler, sondern eine Auskunft.** Zu den meisten + // Filmen hat fanart.tv nichts; das jedes Mal ins Protokoll zu + // schreiben hiesse, es unbrauchbar zu machen. + if (ergebnis.StatusCode == HttpStatusCode.NotFound) return []; + + if (!ergebnis.IsSuccessStatusCode) + { + log.LogDebug("fanart.tv antwortete mit {Code} für {Kennung}.", ergebnis.StatusCode, kennung); + 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, "fanart.tv war für {Kennung} nicht zu erreichen.", kennung); + return []; + } + + if (antwort is null) return []; + + var liste = new List(); + Sammeln(antwort.Movieposter, "poster"); + Sammeln(antwort.Moviebackground, "backdrop"); + Sammeln(antwort.Hdmovielogo, "logo"); + Sammeln(antwort.Movielogo, "logo"); + return liste; + + void Sammeln(List? bilder, string art) + { + foreach (var b in (bilder ?? []).Where(b => !string.IsNullOrWhiteSpace(b.Url))) + { + liste.Add(new Bildkandidat( + art, b.Url!, b.Url!, + // fanart.tv schreibt „00" für „ohne Sprache"; bei uns ist + // das `null`, damit dieselbe Rangfolge greift wie bei TMDB. + string.IsNullOrEmpty(b.Lang) || b.Lang == "00" ? null : b.Lang, + double.TryParse(b.Likes, out var stimmen) ? stimmen : null, + "fanart")); + } + } + } + + private sealed record FanartFilm( + [property: JsonPropertyName("movieposter")] List? Movieposter, + [property: JsonPropertyName("moviebackground")] List? Moviebackground, + [property: JsonPropertyName("hdmovielogo")] List? Hdmovielogo, + [property: JsonPropertyName("movielogo")] List? Movielogo); + + private sealed record FanartBild( + [property: JsonPropertyName("url")] string? Url, + [property: JsonPropertyName("lang")] string? Lang, + [property: JsonPropertyName("likes")] string? Likes); +} + +internal static class JsonHilfe +{ + /// Liest JSON und gibt null zurück, statt zu werfen. + /// + /// + /// Antwortet etwas anderes als erwartet — ein Anmeldeportal, ein Proxy —, + /// ist das kein Grund, einen Scan abzubrechen. + /// + public static async Task ReadFromJsonOderNull(this HttpContent inhalt, CancellationToken ct) + where T : class + { + try + { + return await JsonSerializer.DeserializeAsync( + await inhalt.ReadAsStreamAsync(ct), + new JsonSerializerOptions(JsonSerializerDefaults.Web), ct); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/server/src/Flaro.Metadata/TmdbClient.cs b/server/src/Flaro.Metadata/TmdbClient.cs index fe5067f..be960b0 100644 --- a/server/src/Flaro.Metadata/TmdbClient.cs +++ b/server/src/Flaro.Metadata/TmdbClient.cs @@ -138,13 +138,14 @@ public sealed class TmdbClient( if (film is null or { Id: 0 }) return null; var grund = Als(film, ""); - var (plakat, hintergrund) = Bildwahl( + var (plakat, hintergrund, plakatsprache) = Bildwahl( film.Images, Sprachkürzel(einstellung.Language), grund.Poster, grund.Backdrop); return grund with { Poster = plakat, Backdrop = hintergrund, + PosterLanguage = plakatsprache, ImdbId = Leer(film.ExternalIds?.ImdbId), RuntimeMinutes = film.Runtime > 0 ? film.Runtime : null, Rating = film.VoteAverage > 0 ? film.VoteAverage : null, @@ -214,7 +215,7 @@ public sealed class TmdbClient( if (serie is null or { Id: 0 }) return null; var grund = Als(serie, ""); - var (plakat, hintergrund) = Bildwahl( + var (plakat, hintergrund, _) = Bildwahl( serie.Images, Sprachkürzel(einstellung.Language), grund.Poster, grund.Backdrop); return grund with @@ -449,7 +450,7 @@ public sealed class TmdbClient( art, Bild(b.FilePath, groesse)!.Url, Bild(b.FilePath, "w185")!.Url, string.IsNullOrEmpty(b.Language) ? null : b.Language, - b.VoteAverage)); + b.VoteAverage, "tmdb")); } } } @@ -473,24 +474,21 @@ public sealed class TmdbClient( /// darüber. /// /// + /// Die Rangfolge liegt in — sie gilt für + /// alle Bildquellen, nicht nur für diese. private static int Sprachrang(string? sprache, string gewuenscht) => - string.Equals(sprache, gewuenscht, StringComparison.OrdinalIgnoreCase) ? 0 - : string.IsNullOrEmpty(sprache) ? 1 - : string.Equals(sprache, "en", StringComparison.OrdinalIgnoreCase) ? 2 - : 3; + Bildrang.Nach(sprache, gewuenscht); /// Das passendste Bild einer Liste — oder null, wenn sie leer ist. - private static RemoteImage? Bestes(List? bilder, string sprache, string groesse) + private static TmdbImage? Bestes(List? bilder, string sprache) { var alle = bilder?.Where(b => !string.IsNullOrWhiteSpace(b.FilePath)).ToList(); if (alle is not { Count: > 0 }) return null; - var beste = alle + return alle .OrderBy(b => Sprachrang(b.Language, sprache)) .ThenByDescending(b => b.VoteAverage ?? 0) .First(); - - return Bild(beste.FilePath, groesse); } /// Plakat und Hintergrund in der Sprache, die eingestellt ist. @@ -511,13 +509,20 @@ public sealed class TmdbClient( /// bleibt es beim Feld der Hauptantwort. /// /// - private static (RemoteImage? Plakat, RemoteImage? Hintergrund) Bildwahl( - TmdbImages? bilder, string sprache, RemoteImage? rueckfallPlakat, RemoteImage? rueckfallHintergrund) => - (Bestes(bilder?.Posters, sprache, PosterSize) ?? rueckfallPlakat, - Bestes(bilder?.Backdrops, sprache, BackdropSize) ?? rueckfallHintergrund); + private static (RemoteImage? Plakat, RemoteImage? Hintergrund, string? Plakatsprache) Bildwahl( + TmdbImages? bilder, string sprache, RemoteImage? rueckfallPlakat, RemoteImage? rueckfallHintergrund) + { + var gewaehlt = Bestes(bilder?.Posters, sprache); + return (Bild(gewaehlt?.FilePath, PosterSize) ?? rueckfallPlakat, + Bild(Bestes(bilder?.Backdrops, sprache)?.FilePath, BackdropSize) ?? rueckfallHintergrund, + // **Leer heisst „sprachlos", nicht „unbekannt".** Nur wenn + // gar keine Liste kam, steht hier nichts — dann weiss auch + // niemand, was das Rückfallbild für eine Sprache hat. + gewaehlt is null ? null : gewaehlt.Language ?? ""); + } private static RemoteImage? Schriftzug(TmdbImages? bilder, string sprache) => - Bestes(bilder?.Logos, sprache, LogoSize); + Bild(Bestes(bilder?.Logos, sprache)?.FilePath, LogoSize); private static string? Freigabe(TmdbReleaseDates? quelle, string land) => Leer(quelle?.Results? diff --git a/server/tests/Flaro.Api.Tests/WartungsplanTests.cs b/server/tests/Flaro.Api.Tests/WartungsplanTests.cs index b73e6d6..bfc94ce 100644 --- a/server/tests/Flaro.Api.Tests/WartungsplanTests.cs +++ b/server/tests/Flaro.Api.Tests/WartungsplanTests.cs @@ -177,7 +177,7 @@ public sealed class WartungsplanTests Assert.That(stand!.Select(s => s.Aufgabe), Is.EquivalentTo(new[] { "Sicherung", "Optimieren", "Bilder" })); Assert.That(stand!.First(s => s.Aufgabe == "Sicherung").Tage, Is.EqualTo(3)); - Assert.That(stand.All(s => s.Zuletzt is null), Is.True, "noch lief keine"); + Assert.That(stand!.All(s => s.Zuletzt is null), Is.True, "noch lief keine"); }); } diff --git a/server/tests/Flaro.Metadata.Tests/FanartTests.cs b/server/tests/Flaro.Metadata.Tests/FanartTests.cs new file mode 100644 index 0000000..1522854 --- /dev/null +++ b/server/tests/Flaro.Metadata.Tests/FanartTests.cs @@ -0,0 +1,137 @@ +using System.Net; +using Flaro.Core.Metadata; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Flaro.Metadata.Tests; + +/// Plakate von fanart.tv — der Quelle, bei der die Sprache mitsteht. +/// +/// +/// Der Anlass. „365 Tage" stand mit polnischem Plakat da. Nachgesehen: +/// TMDB hat zu diesem Film 26 Plakate und kein einziges deutsches. Die +/// Auswahl im TMDB-Client nimmt seither das sprachlose statt des polnischen +/// — erfinden kann sie ein deutsches nicht. Genau dafür gibt es diese zweite +/// Quelle. +/// +public class FanartTests +{ + private static FanartClient Client(Attrappe attrappe, string? schluessel = "ein-schluessel") => + new(new HttpClient(attrappe) { BaseAddress = new Uri(FanartClient.BaseAddress) }, + new FesterFanartSchluessel(schluessel), + NullLogger.Instance); + + private const string Antwort = """ + {"name":"Probe","tmdb_id":"500", + "movieposter":[ + {"id":"1","url":"https://assets.fanart.tv/de.jpg","lang":"de","likes":"3"}, + {"id":"2","url":"https://assets.fanart.tv/en.jpg","lang":"en","likes":"9"}, + {"id":"3","url":"https://assets.fanart.tv/ohne.jpg","lang":"00","likes":"5"}], + "moviebackground":[ + {"id":"4","url":"https://assets.fanart.tv/hg.jpg","lang":"","likes":"2"}]} + """; + + [Test] + public async Task Ohne_Schluessel_geht_keine_Anfrage_hinaus() + { + var attrappe = Attrappe.MitJson(Antwort); + + var bilder = await Client(attrappe, schluessel: null).BilderAsync("500"); + + Assert.Multiple(() => + { + Assert.That(bilder, Is.Empty); + Assert.That(attrappe.Aufrufe, Is.Empty, + "fanart.tv verlangt einen persönlichen Schlüssel — ohne ihn wird nicht gefragt"); + }); + } + + [Test] + public async Task Die_Plakate_kommen_mit_ihrer_Sprache() + { + var bilder = await Client(Attrappe.MitJson(Antwort)).BilderAsync("500"); + var plakate = bilder.Where(b => b.Kind == "poster").ToList(); + + Assert.Multiple(() => + { + Assert.That(plakate, Has.Count.EqualTo(3)); + Assert.That(plakate.Select(p => p.Language), Is.EquivalentTo(new[] { "de", "en", null })); + Assert.That(plakate.All(p => p.Quelle == "fanart"), Is.True); + }); + } + + /// „00" ist bei fanart.tv die Schreibweise für „ohne Sprache". + /// + /// + /// Sie durchzureichen hiesse, ein sprachloses Plakat als Sprache „00" zu + /// führen — und die gemeinsame Rangfolge setzte es damit hinter Englisch + /// statt davor. + /// + [Test] + public async Task Die_Null_Null_wird_zu_sprachlos() + { + var bilder = await Client(Attrappe.MitJson(Antwort)).BilderAsync("500"); + + Assert.That(bilder.Any(b => b.Language == "00"), Is.False); + } + + /// Zu den meisten Filmen hat fanart.tv nichts — das ist kein Fehler. + [Test] + public async Task Ein_unbekannter_Film_ergibt_eine_leere_Liste() + { + var bilder = await Client(Attrappe.MitStatus(HttpStatusCode.NotFound)).BilderAsync("500"); + + Assert.That(bilder, Is.Empty); + } + + /// Eine hakende fremde Quelle darf keinen Scan anhalten. + [Test] + public async Task Kein_Netz_ergibt_nichts_statt_eines_Absturzes() + { + var bilder = await Client(Attrappe.DieWirft(new HttpRequestException("weg"))).BilderAsync("500"); + + Assert.That(bilder, Is.Empty); + } + + [Test] + public async Task Der_Schluessel_steht_in_der_Anfrage() + { + var attrappe = Attrappe.MitJson(Antwort); + await Client(attrappe).BilderAsync("500"); + + Assert.That(attrappe.Aufrufe[0], Does.Contain("movies/500").And.Contain("api_key=ein-schluessel")); + } + + /// Ein deutsches Plakat von fanart.tv sticht ein sprachloses von TMDB. + /// + /// + /// Das ist der ganze Zweck der zweiten Quelle, und die Rangfolge dafür + /// liegt im Kern — sie muss für beide Quellen dieselbe sein, sonst kämen + /// die Bilder zweier Anbieter in zwei Reihenfolgen zusammen. + /// + [Test] + public async Task Die_gemeinsame_Rangfolge_stellt_das_deutsche_nach_vorn() + { + var vonFanart = await Client(Attrappe.MitJson(Antwort)).BilderAsync("500"); + var vonTmdb = new[] + { + new Bildkandidat("poster", "tmdb-ohne.jpg", "t", null, 9.5), + new Bildkandidat("poster", "tmdb-en.jpg", "t", "en", 9.9), + }; + + var geordnet = Bildrang + .Geordnet(vonTmdb.Concat(vonFanart.Where(b => b.Kind == "poster")), "de") + .ToList(); + + Assert.Multiple(() => + { + Assert.That(geordnet[0].Url, Does.EndWith("de.jpg")); + Assert.That(geordnet[0].Quelle, Is.EqualTo("fanart")); + Assert.That(geordnet[1].Url, Does.Contain("ohne"), "dann das sprachlose, egal von wem"); + }); + } +} + +public sealed record FesterFanartSchluessel(string? Wert) : IFanartSettingsSource +{ + public Task SchluesselAsync(CancellationToken ct = default) => Task.FromResult(Wert); +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 7430701..14a2c2c 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -1260,9 +1260,13 @@ export const einladungEinloesen = (token: string, name: string, passwort: string }); export const metadatenEinstellung = () => - anfrage<{ hasApiKey: boolean; language: string; usingProjectKey: boolean }>('/settings/metadata'); + anfrage<{ + hasApiKey: boolean; language: string; usingProjectKey: boolean; + /** Ob eine zweite Bildquelle (fanart.tv) eingerichtet ist. */ + hasFanartKey: boolean; + }>('/settings/metadata'); -export const metadatenSetzen = (werte: { apiKey?: string; language?: string }) => +export const metadatenSetzen = (werte: { apiKey?: string; language?: string; fanartApiKey?: string }) => anfrage('/settings/metadata', { method: 'PUT', body: JSON.stringify(werte) }); /* ---------- Zuordnung ---------- */ diff --git a/web/app/src/views/einstellungen/uebersicht.ts b/web/app/src/views/einstellungen/uebersicht.ts index f7634f2..435f7e7 100644 --- a/web/app/src/views/einstellungen/uebersicht.ts +++ b/web/app/src/views/einstellungen/uebersicht.ts @@ -199,9 +199,10 @@ export function serverbereich(geaendert: () => void): HTMLElement { liste.appendChild(zeile( 'Metadaten', // Ohne die Sprache: die steht eine Zeile tiefer und gehört dort hin. - stand.usingProjectKey ? 'Mitgelieferter TMDB-Zugang' + (stand.usingProjectKey ? 'Mitgelieferter TMDB-Zugang' : stand.hasApiKey ? 'Eigener TMDB-Zugang' - : 'Kein Zugang hinterlegt', + : 'Kein Zugang hinterlegt') + + (stand.hasFanartKey ? ' · fanart.tv für Plakate' : ''), stand.usingProjectKey ? 'Ein eigener Zugang löst den mitgelieferten ab.' : 'Nach einer Sprachänderung die Metadaten auffrischen — ' @@ -214,10 +215,26 @@ export function serverbereich(geaendert: () => void): HTMLElement { platzhalter: 'Read Access Token oder API Key', hinweis: 'Leer lassen, um den mitgelieferten zu behalten.', }, + { + name: 'fanart', beschriftung: 'fanart.tv (Plakate)', art: 'password', + platzhalter: stand.hasFanartKey ? 'Hinterlegt — leer lassen, um ihn zu behalten' : 'Persönlicher API-Schlüssel', + // **Warum es diese zweite Quelle gibt.** TMDB hat nicht zu + // jedem Film ein Plakat in jeder Sprache — zu „365 Tage" etwa + // 26 Stück und kein deutsches. Bei fanart.tv trägt jedes + // Plakat seine Sprache, und die Gemeinschaft dort pflegt + // landessprachliche Fassungen. + hinweis: 'Kostenlos, aber persönlich: ein mitgelieferter Schlüssel ' + + 'wäre ein fremder. Ohne ihn bleibt die Quelle aus. Gilt nur ' + + 'für Filme — der Serienweg von fanart.tv braucht eine ' + + 'TVDB-Kennung, die Flaro nicht führt.', + }, ], bestaetigen: 'Speichern', tun: async wert => { - await metadatenSetzen({ apiKey: wert('schluessel') || undefined }); + await metadatenSetzen({ + apiKey: wert('schluessel') || undefined, + fanartApiKey: wert('fanart') || undefined, + }); location.reload(); }, })));