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>());