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
@@ -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;
|
||||
|
||||
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?
|
||||
|
||||
Reference in New Issue
Block a user