Eine zweite Bildquelle, weil TMDB nicht alles hat

„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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-08 21:18:27 +02:00
co-authored by Claude Opus 5
parent 2bd13f0f19
commit 97edb49564
13 changed files with 545 additions and 32 deletions
@@ -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<Bildkandidat> 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));
});
+7 -2
View File
@@ -519,8 +519,12 @@ public sealed record EpisodeResponse(
public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
/// <param name="HasFanartKey">
/// Ob eine zweite Bildquelle eingerichtet ist. Auch hier kommt der Schlüssel
/// selbst nicht zurück — zum Anzeigen genügt, dass einer da ist.
/// </param>
public sealed record MetadataSettingsResponse(
bool HasApiKey, string Language, bool UsingProjectKey);
bool HasApiKey, string Language, bool UsingProjectKey, bool HasFanartKey = false);
/// <summary>Was ein Client über der Mediathek anzeigen muss.</summary>
///
@@ -532,7 +536,8 @@ public sealed record MetadataSettingsResponse(
/// </remarks>
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);
/// <summary>Was der Server über die Wiedergabe entscheidet.</summary>
///
@@ -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();
+27
View File
@@ -35,3 +35,30 @@ public sealed class DatabaseTmdbSettings(FlaroContext db) : ITmdbSettingsSource
zeilen.GetValueOrDefault(LanguageName));
}
}
/// <summary>Der Schlüssel für fanart.tv, aus der Datenbank.</summary>
///
/// <remarks>
/// <b>Kein mitgelieferter.</b> 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.
/// </remarks>
public sealed class DatabaseFanartSettings(FlaroContext db) : IFanartSettingsSource
{
public const string ApiKeyName = "metadata.fanart.apiKey";
private string? _gemerkt;
private bool _gelesen;
public async Task<string?> SchluesselAsync(CancellationToken ct = default)
{
if (_gelesen) return _gemerkt;
var zeile = await db.Settings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == ApiKeyName, ct);
_gelesen = true;
return _gemerkt = string.IsNullOrWhiteSpace(zeile?.Value) ? null : zeile.Value.Trim();
}
}
+13
View File
@@ -98,6 +98,19 @@ builder.Services.AddHttpClient<TmdbClient>(c =>
c.Timeout = TimeSpan.FromSeconds(20);
});
builder.Services.AddScoped<IMetadataProvider>(sp => sp.GetRequiredService<TmdbClient>());
// **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<IFanartSettingsSource, DatabaseFanartSettings>();
builder.Services.AddHttpClient<FanartClient>(c =>
{
c.BaseAddress = new Uri(FanartClient.BaseAddress);
c.Timeout = TimeSpan.FromSeconds(20);
});
builder.Services.AddScoped<IBildquelle>(sp => sp.GetRequiredService<FanartClient>());
builder.Services.AddSingleton(new ImageCacheOptions(pfade.Cache));
builder.Services.AddHttpClient<ImageCache>(c => c.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddScoped<IImageStore>(sp => sp.GetRequiredService<ImageCache>());
@@ -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,
/// <summary>In welcher Sprache das gewählte Plakat ist — <c>null</c> heisst sprachlos.</summary>
///
/// <remarks>
/// <b>Damit eine zweite Quelle nur dann gefragt wird, wenn sie lohnt.</b>
/// 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".
/// </remarks>
string? PosterLanguage = null,
/// <summary>Laufzeit in Minuten, wie die Quelle sie nennt.</summary>
///
/// <remarks>
@@ -248,5 +257,65 @@ public interface IImageReader
/// <b>Mit Vorschau und Sprache.</b> Wer ein Plakat aussucht, sucht meist eins
/// in seiner Sprache, und er will es sehen, bevor er es nimmt — nicht danach.
/// </remarks>
/// <param name="Quelle">Woher es stammt — „tmdb" oder „fanart".</param>
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");
/// <summary>Eine Quelle, die nur Bilder liefert — keine Metadaten.</summary>
///
/// <remarks>
/// <b>Bewusst nicht <see cref="IMetadataProvider"/>.</b> 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.
/// </remarks>
public interface IBildquelle
{
/// <summary>Ob sie überhaupt eingerichtet ist.</summary>
Task<bool> EingerichtetAsync(CancellationToken ct = default);
/// <summary>Alle Bilder zu einem Film, mit Sprachangabe.</summary>
Task<IReadOnlyList<Bildkandidat>> FilmbilderAsync(int tmdbId, CancellationToken ct = default);
}
/// <summary>Wie Bilder nach Sprache geordnet werden.</summary>
///
/// <remarks>
/// <para>
/// <b>Eine Rangfolge für alle Quellen.</b> 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.
/// </para>
/// <para>
/// <b>Sprachlos vor Englisch.</b> 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.
/// </para>
/// </remarks>
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;
/// <summary>Ordnet Kandidaten: erst die Sprache, dann die Stimmen.</summary>
///
/// <remarks>
/// <b>Die Quelle entscheidet nicht mit.</b> 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.
/// </remarks>
public static IEnumerable<Bildkandidat> Geordnet(
IEnumerable<Bildkandidat> kandidaten, string sprache) =>
kandidaten
.OrderBy(b => Nach(b.Language, sprache))
.ThenByDescending(b => b.Vote ?? 0);
}
+51 -1
View File
@@ -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(
}
}
/// <summary>Das Plakat, das am besten zur eingestellten Sprache passt.</summary>
///
/// <remarks>
/// <para>
/// <b>Die zweite Quelle wird nur gefragt, wenn sie lohnt.</b> 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".
/// </para>
/// <para>
/// <b>Gemessen an „365 Tage":</b> 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.
/// </para>
/// <para>
/// <b>Und nur ein landessprachliches sticht.</b> 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.
/// </para>
/// </remarks>
private async Task<RemoteImage?> 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<string?> BildAblegen(RemoteImage? bild, CancellationToken ct) =>
bild is null ? null : await bilder.StoreAsync(bild, ct);
+159
View File
@@ -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;
/// <summary>Woher der Schlüssel für fanart.tv kommt.</summary>
public interface IFanartSettingsSource
{
Task<string?> SchluesselAsync(CancellationToken ct = default);
}
/// <summary>Plakate von fanart.tv — die Quelle mit Sprachangabe.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum es diese zweite Quelle gibt.</b> „365 Tage" stand mit polnischem
/// Plakat da. Nachgesehen: TMDB hat 26 Plakate zu diesem Film und kein
/// einziges deutsches. Die Auswahl in <see cref="TmdbClient"/> nimmt
/// seither das sprachneutrale statt des polnischen — aber ein deutsches
/// kann sie nicht erfinden.
/// </para>
/// <para>
/// <b>fanart.tv ist die Quelle, bei der die Sprache mitsteht.</b> Jedes
/// Plakat trägt ein <c>lang</c>, 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.
/// </para>
/// <para>
/// <b>Nur Filme.</b> 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.
/// </para>
/// <para>
/// <b>Ohne Schlüssel geschieht nichts.</b> fanart.tv verlangt einen
/// persönlichen, kostenlosen Schlüssel — es gibt keinen mitgelieferten wie
/// bei TMDB. Fehlt er, geht keine Anfrage hinaus.
/// </para>
/// </remarks>
public sealed class FanartClient(
HttpClient http,
IFanartSettingsSource einstellungen,
ILogger<FanartClient> log) : IBildquelle
{
public const string BaseAddress = "https://webservice.fanart.tv/v3/";
/// <summary>Wie gross ein Plakat von dort ist — fanart.tv liefert eine Grösse.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
public async Task<bool> EingerichtetAsync(CancellationToken ct = default) =>
!string.IsNullOrWhiteSpace(await einstellungen.SchluesselAsync(ct));
/// <summary>Alle Plakate und Hintergründe zu einem Film.</summary>
///
/// <param name="kennung">Die TMDB-Kennung oder die IMDb-Kennung; fanart.tv nimmt beide.</param>
public Task<IReadOnlyList<Bildkandidat>> FilmbilderAsync(int tmdbId, CancellationToken ct = default) =>
BilderAsync(tmdbId.ToString(System.Globalization.CultureInfo.InvariantCulture), ct);
public async Task<IReadOnlyList<Bildkandidat>> 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<FanartFilm>(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<Bildkandidat>();
Sammeln(antwort.Movieposter, "poster");
Sammeln(antwort.Moviebackground, "backdrop");
Sammeln(antwort.Hdmovielogo, "logo");
Sammeln(antwort.Movielogo, "logo");
return liste;
void Sammeln(List<FanartBild>? 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<FanartBild>? Movieposter,
[property: JsonPropertyName("moviebackground")] List<FanartBild>? Moviebackground,
[property: JsonPropertyName("hdmovielogo")] List<FanartBild>? Hdmovielogo,
[property: JsonPropertyName("movielogo")] List<FanartBild>? Movielogo);
private sealed record FanartBild(
[property: JsonPropertyName("url")] string? Url,
[property: JsonPropertyName("lang")] string? Lang,
[property: JsonPropertyName("likes")] string? Likes);
}
internal static class JsonHilfe
{
/// <summary>Liest JSON und gibt <c>null</c> zurück, statt zu werfen.</summary>
///
/// <remarks>
/// Antwortet etwas anderes als erwartet — ein Anmeldeportal, ein Proxy —,
/// ist das kein Grund, einen Scan abzubrechen.
/// </remarks>
public static async Task<T?> ReadFromJsonOderNull<T>(this HttpContent inhalt, CancellationToken ct)
where T : class
{
try
{
return await JsonSerializer.DeserializeAsync<T>(
await inhalt.ReadAsStreamAsync(ct),
new JsonSerializerOptions(JsonSerializerDefaults.Web), ct);
}
catch (JsonException)
{
return null;
}
}
}
+21 -16
View File
@@ -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.
/// </para>
/// </remarks>
/// <remarks>Die Rangfolge liegt in <see cref="Bildrang"/> — sie gilt für
/// alle Bildquellen, nicht nur für diese.</remarks>
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);
/// <summary>Das passendste Bild einer Liste — oder <c>null</c>, wenn sie leer ist.</summary>
private static RemoteImage? Bestes(List<TmdbImage>? bilder, string sprache, string groesse)
private static TmdbImage? Bestes(List<TmdbImage>? 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);
}
/// <summary>Plakat und Hintergrund in der Sprache, die eingestellt ist.</summary>
@@ -511,13 +509,20 @@ public sealed class TmdbClient(
/// bleibt es beim Feld der Hauptantwort.
/// </para>
/// </remarks>
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?
@@ -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");
});
}
@@ -0,0 +1,137 @@
using System.Net;
using Flaro.Core.Metadata;
using Microsoft.Extensions.Logging.Abstractions;
namespace Flaro.Metadata.Tests;
/// <summary>Plakate von fanart.tv — der Quelle, bei der die Sprache mitsteht.</summary>
///
/// <remarks>
/// <b>Der Anlass.</b> „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.
/// </remarks>
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<FanartClient>.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);
});
}
/// <summary>„00" ist bei fanart.tv die Schreibweise für „ohne Sprache".</summary>
///
/// <remarks>
/// Sie durchzureichen hiesse, ein sprachloses Plakat als Sprache „00" zu
/// führen — und die gemeinsame Rangfolge setzte es damit hinter Englisch
/// statt davor.
/// </remarks>
[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);
}
/// <summary>Zu den meisten Filmen hat fanart.tv nichts — das ist kein Fehler.</summary>
[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);
}
/// <summary>Eine hakende fremde Quelle darf keinen Scan anhalten.</summary>
[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"));
}
/// <summary>Ein deutsches Plakat von fanart.tv sticht ein sprachloses von TMDB.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
[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<string?> SchluesselAsync(CancellationToken ct = default) => Task.FromResult(Wert);
}
+6 -2
View File
@@ -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<void>('/settings/metadata', { method: 'PUT', body: JSON.stringify(werte) });
/* ---------- Zuordnung ---------- */
+20 -3
View File
@@ -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();
},
})));