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:
co-authored by
Claude Opus 5
parent
2bd13f0f19
commit
97edb49564
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Globalization;
|
||||||
using Flaro.Core.Metadata;
|
using Flaro.Core.Metadata;
|
||||||
using Flaro.Data;
|
using Flaro.Data;
|
||||||
using Flaro.Metadata;
|
using Flaro.Metadata;
|
||||||
@@ -34,7 +35,8 @@ public static class ArtworkEndpoints
|
|||||||
.RequireAuthorization(FlaroPolicies.Administrator);
|
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
gruppe.MapGet("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
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 serie = art == "series";
|
||||||
var kennung = serie
|
var kennung = serie
|
||||||
@@ -53,9 +55,28 @@ public static class ArtworkEndpoints
|
|||||||
|
|
||||||
// Ohne Zuordnung gibt es nichts zu wählen — dann ist die
|
// Ohne Zuordnung gibt es nichts zu wählen — dann ist die
|
||||||
// Zuordnung das Problem, nicht das Bild.
|
// Zuordnung das Problem, nicht das Bild.
|
||||||
var kandidaten = kennung is { } k
|
IReadOnlyList<Bildkandidat> kandidaten = [];
|
||||||
? await tmdb.BilderAsync(k, serie, ct)
|
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));
|
return Results.Ok(new Bildauswahl(aktuell, kandidaten));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -519,8 +519,12 @@ public sealed record EpisodeResponse(
|
|||||||
|
|
||||||
public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
|
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(
|
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>
|
/// <summary>Was ein Client über der Mediathek anzeigen muss.</summary>
|
||||||
///
|
///
|
||||||
@@ -532,7 +536,8 @@ public sealed record MetadataSettingsResponse(
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed record AttributionResponse(string Text, string LogoUrl, string Url);
|
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>
|
/// <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) =>
|
app.MapGet("/api/v1/settings/metadata", async (FlaroContext db) =>
|
||||||
{
|
{
|
||||||
var zeilen = await db.Settings.AsNoTracking()
|
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);
|
.ToDictionaryAsync(s => s.Key, s => s.Value);
|
||||||
|
|
||||||
// **Der Schlüssel selbst kommt nicht zurück.** Er gäbe jedem
|
// **Der Schlüssel selbst kommt nicht zurück.** Er gäbe jedem
|
||||||
@@ -30,7 +32,9 @@ public static class SettingsEndpoints
|
|||||||
Language: aufgelöst.Language,
|
Language: aufgelöst.Language,
|
||||||
// Damit in der Oberfläche steht, ob der mitgelieferte Schlüssel
|
// Damit in der Oberfläche steht, ob der mitgelieferte Schlüssel
|
||||||
// benutzt wird — und dass ein eigener ihn ablösen würde.
|
// benutzt wird — und dass ein eigener ihn ablösen würde.
|
||||||
UsingProjectKey: aufgelöst.UsingProjectKey));
|
UsingProjectKey: aufgelöst.UsingProjectKey,
|
||||||
|
HasFanartKey: !string.IsNullOrWhiteSpace(
|
||||||
|
zeilen.GetValueOrDefault(DatabaseFanartSettings.ApiKeyName))));
|
||||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
// **In welcher Sprache der Bestand vorliegt.**
|
// **In welcher Sprache der Bestand vorliegt.**
|
||||||
@@ -88,6 +92,8 @@ public static class SettingsEndpoints
|
|||||||
MetadataSettingsRequest anfrage, FlaroContext db) =>
|
MetadataSettingsRequest anfrage, FlaroContext db) =>
|
||||||
{
|
{
|
||||||
if (anfrage.ApiKey is not null) await Setzen(db, DatabaseTmdbSettings.ApiKeyName, anfrage.ApiKey.Trim());
|
if (anfrage.ApiKey is not null) await Setzen(db, DatabaseTmdbSettings.ApiKeyName, anfrage.ApiKey.Trim());
|
||||||
|
if (anfrage.FanartApiKey is not null)
|
||||||
|
await Setzen(db, DatabaseFanartSettings.ApiKeyName, anfrage.FanartApiKey.Trim());
|
||||||
if (anfrage.Language is not null)
|
if (anfrage.Language is not null)
|
||||||
{
|
{
|
||||||
var sprache = anfrage.Language.Trim();
|
var sprache = anfrage.Language.Trim();
|
||||||
|
|||||||
@@ -35,3 +35,30 @@ public sealed class DatabaseTmdbSettings(FlaroContext db) : ITmdbSettingsSource
|
|||||||
zeilen.GetValueOrDefault(LanguageName));
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -98,6 +98,19 @@ builder.Services.AddHttpClient<TmdbClient>(c =>
|
|||||||
c.Timeout = TimeSpan.FromSeconds(20);
|
c.Timeout = TimeSpan.FromSeconds(20);
|
||||||
});
|
});
|
||||||
builder.Services.AddScoped<IMetadataProvider>(sp => sp.GetRequiredService<TmdbClient>());
|
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.AddSingleton(new ImageCacheOptions(pfade.Cache));
|
||||||
builder.Services.AddHttpClient<ImageCache>(c => c.Timeout = TimeSpan.FromSeconds(30));
|
builder.Services.AddHttpClient<ImageCache>(c => c.Timeout = TimeSpan.FromSeconds(30));
|
||||||
builder.Services.AddScoped<IImageStore>(sp => sp.GetRequiredService<ImageCache>());
|
builder.Services.AddScoped<IImageStore>(sp => sp.GetRequiredService<ImageCache>());
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ public enum CreditKind { Actor, Director, Writer }
|
|||||||
public sealed record MovieMetadata(
|
public sealed record MovieMetadata(
|
||||||
int ProviderId, string Title, int? Year, string? Overview,
|
int ProviderId, string Title, int? Year, string? Overview,
|
||||||
RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null,
|
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>
|
/// <summary>Laufzeit in Minuten, wie die Quelle sie nennt.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -248,5 +257,65 @@ public interface IImageReader
|
|||||||
/// <b>Mit Vorschau und Sprache.</b> Wer ein Plakat aussucht, sucht meist eins
|
/// <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.
|
/// in seiner Sprache, und er will es sehen, bevor er es nimmt — nicht danach.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <param name="Quelle">Woher es stammt — „tmdb" oder „fanart".</param>
|
||||||
public sealed record Bildkandidat(
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public sealed class MetadataEnricher(
|
|||||||
IMetadataProvider anbieter,
|
IMetadataProvider anbieter,
|
||||||
IImageStore bilder,
|
IImageStore bilder,
|
||||||
IImageReader bildLeser,
|
IImageReader bildLeser,
|
||||||
|
IBildquelle zweiteBildquelle,
|
||||||
ISidecarStore beiwerk,
|
ISidecarStore beiwerk,
|
||||||
Zusammenlegung zusammenlegung,
|
Zusammenlegung zusammenlegung,
|
||||||
TimeProvider uhr,
|
TimeProvider uhr,
|
||||||
@@ -229,7 +230,10 @@ public sealed class MetadataEnricher(
|
|||||||
// **Was von Hand gewählt wurde, bleibt.** Ohne diese Prüfung
|
// **Was von Hand gewählt wurde, bleibt.** Ohne diese Prüfung
|
||||||
// überschreibt jedes Auffrischen die Auswahl wieder — und wer
|
// überschreibt jedes Auffrischen die Auswahl wieder — und wer
|
||||||
// einmal ein besseres Plakat gesucht hat, sucht es jedes Mal neu.
|
// 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.BackdropPinned) film.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
|
||||||
if (!film.LogoPinned) film.LogoImage = await BildAblegen(gefunden.Logo, ct);
|
if (!film.LogoPinned) film.LogoImage = await BildAblegen(gefunden.Logo, ct);
|
||||||
film.RuntimeMinutes = gefunden.RuntimeMinutes;
|
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) =>
|
private async Task<string?> BildAblegen(RemoteImage? bild, CancellationToken ct) =>
|
||||||
bild is null ? null : await bilder.StoreAsync(bild, ct);
|
bild is null ? null : await bilder.StoreAsync(bild, ct);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,13 +138,14 @@ public sealed class TmdbClient(
|
|||||||
if (film is null or { Id: 0 }) return null;
|
if (film is null or { Id: 0 }) return null;
|
||||||
|
|
||||||
var grund = Als(film, "");
|
var grund = Als(film, "");
|
||||||
var (plakat, hintergrund) = Bildwahl(
|
var (plakat, hintergrund, plakatsprache) = Bildwahl(
|
||||||
film.Images, Sprachkürzel(einstellung.Language), grund.Poster, grund.Backdrop);
|
film.Images, Sprachkürzel(einstellung.Language), grund.Poster, grund.Backdrop);
|
||||||
|
|
||||||
return grund with
|
return grund with
|
||||||
{
|
{
|
||||||
Poster = plakat,
|
Poster = plakat,
|
||||||
Backdrop = hintergrund,
|
Backdrop = hintergrund,
|
||||||
|
PosterLanguage = plakatsprache,
|
||||||
ImdbId = Leer(film.ExternalIds?.ImdbId),
|
ImdbId = Leer(film.ExternalIds?.ImdbId),
|
||||||
RuntimeMinutes = film.Runtime > 0 ? film.Runtime : null,
|
RuntimeMinutes = film.Runtime > 0 ? film.Runtime : null,
|
||||||
Rating = film.VoteAverage > 0 ? film.VoteAverage : 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;
|
if (serie is null or { Id: 0 }) return null;
|
||||||
|
|
||||||
var grund = Als(serie, "");
|
var grund = Als(serie, "");
|
||||||
var (plakat, hintergrund) = Bildwahl(
|
var (plakat, hintergrund, _) = Bildwahl(
|
||||||
serie.Images, Sprachkürzel(einstellung.Language), grund.Poster, grund.Backdrop);
|
serie.Images, Sprachkürzel(einstellung.Language), grund.Poster, grund.Backdrop);
|
||||||
|
|
||||||
return grund with
|
return grund with
|
||||||
@@ -449,7 +450,7 @@ public sealed class TmdbClient(
|
|||||||
art, Bild(b.FilePath, groesse)!.Url,
|
art, Bild(b.FilePath, groesse)!.Url,
|
||||||
Bild(b.FilePath, "w185")!.Url,
|
Bild(b.FilePath, "w185")!.Url,
|
||||||
string.IsNullOrEmpty(b.Language) ? null : b.Language,
|
string.IsNullOrEmpty(b.Language) ? null : b.Language,
|
||||||
b.VoteAverage));
|
b.VoteAverage, "tmdb"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -473,24 +474,21 @@ public sealed class TmdbClient(
|
|||||||
/// darüber.
|
/// darüber.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </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) =>
|
private static int Sprachrang(string? sprache, string gewuenscht) =>
|
||||||
string.Equals(sprache, gewuenscht, StringComparison.OrdinalIgnoreCase) ? 0
|
Bildrang.Nach(sprache, gewuenscht);
|
||||||
: string.IsNullOrEmpty(sprache) ? 1
|
|
||||||
: string.Equals(sprache, "en", StringComparison.OrdinalIgnoreCase) ? 2
|
|
||||||
: 3;
|
|
||||||
|
|
||||||
/// <summary>Das passendste Bild einer Liste — oder <c>null</c>, wenn sie leer ist.</summary>
|
/// <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();
|
var alle = bilder?.Where(b => !string.IsNullOrWhiteSpace(b.FilePath)).ToList();
|
||||||
if (alle is not { Count: > 0 }) return null;
|
if (alle is not { Count: > 0 }) return null;
|
||||||
|
|
||||||
var beste = alle
|
return alle
|
||||||
.OrderBy(b => Sprachrang(b.Language, sprache))
|
.OrderBy(b => Sprachrang(b.Language, sprache))
|
||||||
.ThenByDescending(b => b.VoteAverage ?? 0)
|
.ThenByDescending(b => b.VoteAverage ?? 0)
|
||||||
.First();
|
.First();
|
||||||
|
|
||||||
return Bild(beste.FilePath, groesse);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Plakat und Hintergrund in der Sprache, die eingestellt ist.</summary>
|
/// <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.
|
/// bleibt es beim Feld der Hauptantwort.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static (RemoteImage? Plakat, RemoteImage? Hintergrund) Bildwahl(
|
private static (RemoteImage? Plakat, RemoteImage? Hintergrund, string? Plakatsprache) Bildwahl(
|
||||||
TmdbImages? bilder, string sprache, RemoteImage? rueckfallPlakat, RemoteImage? rueckfallHintergrund) =>
|
TmdbImages? bilder, string sprache, RemoteImage? rueckfallPlakat, RemoteImage? rueckfallHintergrund)
|
||||||
(Bestes(bilder?.Posters, sprache, PosterSize) ?? rueckfallPlakat,
|
{
|
||||||
Bestes(bilder?.Backdrops, sprache, BackdropSize) ?? 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) =>
|
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) =>
|
private static string? Freigabe(TmdbReleaseDates? quelle, string land) =>
|
||||||
Leer(quelle?.Results?
|
Leer(quelle?.Results?
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ public sealed class WartungsplanTests
|
|||||||
Assert.That(stand!.Select(s => s.Aufgabe),
|
Assert.That(stand!.Select(s => s.Aufgabe),
|
||||||
Is.EquivalentTo(new[] { "Sicherung", "Optimieren", "Bilder" }));
|
Is.EquivalentTo(new[] { "Sicherung", "Optimieren", "Bilder" }));
|
||||||
Assert.That(stand!.First(s => s.Aufgabe == "Sicherung").Tage, Is.EqualTo(3));
|
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
@@ -1260,9 +1260,13 @@ export const einladungEinloesen = (token: string, name: string, passwort: string
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadatenEinstellung = () =>
|
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) });
|
anfrage<void>('/settings/metadata', { method: 'PUT', body: JSON.stringify(werte) });
|
||||||
|
|
||||||
/* ---------- Zuordnung ---------- */
|
/* ---------- Zuordnung ---------- */
|
||||||
|
|||||||
@@ -199,9 +199,10 @@ export function serverbereich(geaendert: () => void): HTMLElement {
|
|||||||
liste.appendChild(zeile(
|
liste.appendChild(zeile(
|
||||||
'Metadaten',
|
'Metadaten',
|
||||||
// Ohne die Sprache: die steht eine Zeile tiefer und gehört dort hin.
|
// 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'
|
: stand.hasApiKey ? 'Eigener TMDB-Zugang'
|
||||||
: 'Kein Zugang hinterlegt',
|
: 'Kein Zugang hinterlegt')
|
||||||
|
+ (stand.hasFanartKey ? ' · fanart.tv für Plakate' : ''),
|
||||||
stand.usingProjectKey
|
stand.usingProjectKey
|
||||||
? 'Ein eigener Zugang löst den mitgelieferten ab.'
|
? 'Ein eigener Zugang löst den mitgelieferten ab.'
|
||||||
: 'Nach einer Sprachänderung die Metadaten auffrischen — '
|
: 'Nach einer Sprachänderung die Metadaten auffrischen — '
|
||||||
@@ -214,10 +215,26 @@ export function serverbereich(geaendert: () => void): HTMLElement {
|
|||||||
platzhalter: 'Read Access Token oder API Key',
|
platzhalter: 'Read Access Token oder API Key',
|
||||||
hinweis: 'Leer lassen, um den mitgelieferten zu behalten.',
|
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',
|
bestaetigen: 'Speichern',
|
||||||
tun: async wert => {
|
tun: async wert => {
|
||||||
await metadatenSetzen({ apiKey: wert('schluessel') || undefined });
|
await metadatenSetzen({
|
||||||
|
apiKey: wert('schluessel') || undefined,
|
||||||
|
fanartApiKey: wert('fanart') || undefined,
|
||||||
|
});
|
||||||
location.reload();
|
location.reload();
|
||||||
},
|
},
|
||||||
})));
|
})));
|
||||||
|
|||||||
Reference in New Issue
Block a user