Halo heisst ab hier Flaro — die Marke stand im Weg
Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging daneben. **Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler. Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim letzten Mal stattfand. --- 2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`, `Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen `FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und das Image. **Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes Ersetzen hätte den sonst zerlegt. `docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden in die Irre führt. **Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag: `com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe. 308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS bauen, das Image baut, und ein frischer Container läuft durch Einrichtung und Anmeldung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f98b995050
commit
a7cf6c5def
@@ -0,0 +1,318 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Flaro.Core.Metadata;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Flaro.Metadata;
|
||||
|
||||
/// <summary>Fragt TMDB nach Titeln, Beschreibungen und Bildern.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Ein Fehlschlag ist kein Abbruch.</b> Antwortet TMDB nicht — kein Netz,
|
||||
/// Kontingent erschöpft, Wartung —, kommt <c>null</c> zurück und der Titel
|
||||
/// bleibt beim Dateinamen. Ein Scan, der an einem fremden Dienst scheitert,
|
||||
/// wäre der falsche Entwurf: die Mediathek steht dann leer da, obwohl die
|
||||
/// Dateien tadellos vorhanden sind.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Ein falscher Schlüssel ist dagegen sehr wohl etwas, das jemand erfahren
|
||||
/// muss</b> — sonst sucht man stundenlang, warum keine Bilder erscheinen.
|
||||
/// Deshalb nur dafür eine Ausnahme.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TmdbClient(
|
||||
HttpClient http,
|
||||
ITmdbSettingsSource einstellungen,
|
||||
ILogger<TmdbClient> log) : IMetadataProvider
|
||||
{
|
||||
public const string BaseAddress = "https://api.themoviedb.org/3/";
|
||||
|
||||
/// <summary>w500 für Plakate, w1280 für Hintergründe — genug für jeden Bildschirm.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Die Originalgrösse wäre bei einem Bestand mit tausend Titeln mehrere
|
||||
/// Gigabyte Cache, ohne dass es auf einem Telefon je sichtbar würde.
|
||||
/// </remarks>
|
||||
private const string PosterSize = "w500";
|
||||
private const string BackdropSize = "w1280";
|
||||
private const string StillSize = "w300";
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>Ein Lese-Token ist ein JWT und fängt immer so an.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit beides funktioniert, was auf TMDBs Kontoseite steht.</b> Dort
|
||||
/// liegen zwei Dinge nebeneinander — „API Key" und „API Read Access
|
||||
/// Token" —, und wer eines davon kopiert, soll nicht erst herausfinden
|
||||
/// müssen, welches gemeint war.
|
||||
/// </remarks>
|
||||
private const string JwtAnfang = "eyJ";
|
||||
|
||||
public async Task<bool> IsConfiguredAsync(CancellationToken ct = default) =>
|
||||
!string.IsNullOrWhiteSpace((await einstellungen.GetAsync(ct)).ApiKey);
|
||||
|
||||
public async Task<MovieMetadata?> FindMovieAsync(string title, int? year, CancellationToken ct = default)
|
||||
{
|
||||
var treffer = await SuchenAsync<TmdbMovie>("search/movie", title,
|
||||
year is { } j ? ("year", j.ToString()) : null, m => m.Title, ct);
|
||||
return treffer is null ? null : await MitImdb(Als(treffer, title), "movie", ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(
|
||||
string title, int? year, CancellationToken ct = default)
|
||||
{
|
||||
var alle = await AlleAsync<TmdbMovie>("search/movie", title,
|
||||
year is { } j ? ("year", j.ToString()) : null, ct);
|
||||
return alle.Select(m => Als(m, title)).ToList();
|
||||
}
|
||||
|
||||
public async Task<MovieMetadata?> GetMovieAsync(int providerId, CancellationToken ct = default)
|
||||
{
|
||||
var einstellung = await einstellungen.GetAsync(ct);
|
||||
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
|
||||
|
||||
var film = await HolenAsync<TmdbMovie>(
|
||||
$"movie/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}", einstellung, ct);
|
||||
return film is null ? null : await MitImdb(Als(film, ""), "movie", ct);
|
||||
}
|
||||
|
||||
private static MovieMetadata Als(TmdbMovie roh, string rückfall) => new(
|
||||
roh.Id, roh.Title ?? rückfall, JahrAus(roh.ReleaseDate), Leer(roh.Overview),
|
||||
Bild(roh.PosterPath, PosterSize), Bild(roh.BackdropPath, BackdropSize));
|
||||
|
||||
public async Task<SeriesMetadata?> FindSeriesAsync(string title, int? year, CancellationToken ct = default)
|
||||
{
|
||||
var treffer = await SuchenAsync<TmdbSeries>("search/tv", title,
|
||||
year is { } j ? ("first_air_date_year", j.ToString()) : null, s => s.Name, ct);
|
||||
return treffer is null ? null : await MitImdb(Als(treffer, title), "tv", ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SeriesMetadata>> SearchSeriesAsync(
|
||||
string title, int? year, CancellationToken ct = default)
|
||||
{
|
||||
var alle = await AlleAsync<TmdbSeries>("search/tv", title,
|
||||
year is { } j ? ("first_air_date_year", j.ToString()) : null, ct);
|
||||
return alle.Select(s => Als(s, title)).ToList();
|
||||
}
|
||||
|
||||
public async Task<SeriesMetadata?> GetSeriesAsync(int providerId, CancellationToken ct = default)
|
||||
{
|
||||
var einstellung = await einstellungen.GetAsync(ct);
|
||||
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
|
||||
|
||||
var serie = await HolenAsync<TmdbSeries>(
|
||||
$"tv/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}", einstellung, ct);
|
||||
return serie is null ? null : await MitImdb(Als(serie, ""), "tv", ct);
|
||||
}
|
||||
|
||||
private static SeriesMetadata Als(TmdbSeries roh, string rückfall) => new(
|
||||
roh.Id, roh.Name ?? rückfall, JahrAus(roh.FirstAirDate), Leer(roh.Overview),
|
||||
Bild(roh.PosterPath, PosterSize), Bild(roh.BackdropPath, BackdropSize));
|
||||
|
||||
/// <summary>Holt die IMDb-Kennung nach — eine Anfrage je neuem Treffer.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die Suche bei TMDB liefert sie nicht mit.</b> Gebraucht wird sie für
|
||||
/// die NFO-Datei: wer später auf ein anderes Werkzeug wechselt, findet
|
||||
/// damit denselben Titel wieder, auch wenn das Werkzeug TMDB gar nicht
|
||||
/// kennt.
|
||||
///
|
||||
/// Scheitert der Nachschlag, bleibt sie leer — das ist kein Grund, den
|
||||
/// ganzen Treffer wegzuwerfen.
|
||||
/// </remarks>
|
||||
private async Task<MovieMetadata> MitImdb(MovieMetadata film, string art, CancellationToken ct) =>
|
||||
film with { ImdbId = await ImdbAsync(art, film.ProviderId, ct) };
|
||||
|
||||
private async Task<SeriesMetadata> MitImdb(SeriesMetadata serie, string art, CancellationToken ct) =>
|
||||
serie with { ImdbId = await ImdbAsync(art, serie.ProviderId, ct) };
|
||||
|
||||
private async Task<string?> ImdbAsync(string art, int kennung, CancellationToken ct)
|
||||
{
|
||||
var einstellung = await einstellungen.GetAsync(ct);
|
||||
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
|
||||
|
||||
var fremde = await HolenAsync<TmdbExternalIds>($"{art}/{kennung}/external_ids", einstellung, ct);
|
||||
return Leer(fremde?.ImdbId);
|
||||
}
|
||||
|
||||
public async Task<EpisodeMetadata?> FindEpisodeAsync(
|
||||
int seriesProviderId, int season, int episode, CancellationToken ct = default)
|
||||
{
|
||||
var einstellung = await einstellungen.GetAsync(ct);
|
||||
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
|
||||
|
||||
var pfad = $"tv/{seriesProviderId}/season/{season}/episode/{episode}" +
|
||||
$"?language={Uri.EscapeDataString(einstellung.Language)}";
|
||||
|
||||
var folge = await HolenAsync<TmdbEpisode>(pfad, einstellung, ct);
|
||||
if (folge is null) return null;
|
||||
|
||||
return new EpisodeMetadata(Leer(folge.Name), Leer(folge.Overview), Bild(folge.StillPath, StillSize));
|
||||
}
|
||||
|
||||
/// <summary>Die ganze Trefferliste — für die Auswahl von Hand.</summary>
|
||||
private async Task<List<T>> AlleAsync<T>(
|
||||
string pfad, string titel, (string Name, string Wert)? zusatz, CancellationToken ct) where T : class
|
||||
{
|
||||
var einstellung = await einstellungen.GetAsync(ct);
|
||||
if (string.IsNullOrWhiteSpace(einstellung.ApiKey) || string.IsNullOrWhiteSpace(titel)) return [];
|
||||
|
||||
var abfrage = $"{pfad}?language={Uri.EscapeDataString(einstellung.Language)}" +
|
||||
$"&query={Uri.EscapeDataString(titel)}";
|
||||
if (zusatz is { } z) abfrage += $"&{z.Name}={Uri.EscapeDataString(z.Wert)}";
|
||||
|
||||
var antwort = await HolenAsync<TmdbSearch<T>>(abfrage, einstellung, ct);
|
||||
// Gedeckelt: wer aus fünfzig Vorschlägen wählen soll, wählt gar nicht.
|
||||
return antwort?.Results?.Take(10).ToList() ?? [];
|
||||
}
|
||||
|
||||
private async Task<T?> SuchenAsync<T>(
|
||||
string pfad, string titel, (string Name, string Wert)? zusatz,
|
||||
Func<T, string?> titelVon, CancellationToken ct) where T : class
|
||||
{
|
||||
var einstellung = await einstellungen.GetAsync(ct);
|
||||
if (string.IsNullOrWhiteSpace(einstellung.ApiKey) || string.IsNullOrWhiteSpace(titel)) return null;
|
||||
|
||||
var abfrage = $"{pfad}?language={Uri.EscapeDataString(einstellung.Language)}" +
|
||||
$"&query={Uri.EscapeDataString(titel)}";
|
||||
if (zusatz is { } z) abfrage += $"&{z.Name}={Uri.EscapeDataString(z.Wert)}";
|
||||
|
||||
var antwort = await HolenAsync<TmdbSearch<T>>(abfrage, einstellung, ct);
|
||||
var treffer = antwort?.Results;
|
||||
if (treffer is null || treffer.Count == 0) return null;
|
||||
|
||||
// **Ein genau passender Name sticht den beliebtesten.**
|
||||
//
|
||||
// TMDB sortiert nach Beliebtheit. Ohne Jahresangabe führt das in die
|
||||
// Irre: die Suche nach „Dark" lieferte „Dark Shadows" von 1966 statt
|
||||
// der Serie von 2017 — am echten Dienst gemessen, nicht vermutet.
|
||||
// Steht ein Treffer mit genau diesem Namen in der Liste, ist er
|
||||
// gemeint.
|
||||
//
|
||||
// Nur bei Gleichheit, nicht bei Ähnlichkeit: eine eigene Bewertung zu
|
||||
// bauen hiesse, TMDBs Rangfolge nachzubauen — mit weniger Daten.
|
||||
var genau = treffer.FirstOrDefault(e =>
|
||||
string.Equals(titelVon(e)?.Trim(), titel.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return genau ?? treffer[0];
|
||||
}
|
||||
|
||||
/// <summary>Holt eine Antwort — mit Bearer, wo es geht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Der Lese-Token wandert in die Kopfzeile, nicht in die Adresse.</b>
|
||||
/// Ein <c>?api_key=…</c> steht in jedem Zugriffsprotokoll, in jedem
|
||||
/// Reverse Proxy und in jeder Fehlermeldung, die eine Adresse zitiert.
|
||||
/// Eine Kopfzeile tut das nicht. Dazu kann ein Lese-Token nichts ändern,
|
||||
/// nur lesen — bei einem Schlüssel, der in einem offenen Projekt
|
||||
/// mitgeliefert wird, ist das der Unterschied, auf den es ankommt.
|
||||
///
|
||||
/// Der ältere Schlüssel geht trotzdem: er kann nur in die Adresse.
|
||||
/// </remarks>
|
||||
private async Task<T?> HolenAsync<T>(
|
||||
string pfadMitAbfrage, TmdbSettings einstellung, CancellationToken ct) where T : class
|
||||
{
|
||||
var zugang = einstellung.ApiKey!;
|
||||
var alsBearer = zugang.StartsWith(JwtAnfang, StringComparison.Ordinal);
|
||||
var adresse = alsBearer
|
||||
? pfadMitAbfrage
|
||||
: pfadMitAbfrage + (pfadMitAbfrage.Contains('?') ? "&" : "?")
|
||||
+ $"api_key={Uri.EscapeDataString(zugang)}";
|
||||
|
||||
try
|
||||
{
|
||||
using var anfrage = new HttpRequestMessage(HttpMethod.Get, adresse);
|
||||
if (alsBearer) anfrage.Headers.Authorization = new("Bearer", zugang);
|
||||
|
||||
using var antwort = await http.SendAsync(anfrage, ct);
|
||||
|
||||
if (antwort.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||
throw new MetadataCredentialsException(
|
||||
"TMDB weist den hinterlegten Schlüssel zurück. Prüfe ihn in den Servereinstellungen.");
|
||||
|
||||
// 404 heisst bei TMDB schlicht „gibt es nicht" — etwa eine Folge,
|
||||
// die dort noch nicht eingetragen ist.
|
||||
if (antwort.StatusCode == HttpStatusCode.NotFound) return null;
|
||||
|
||||
if (!antwort.IsSuccessStatusCode)
|
||||
{
|
||||
log.LogWarning("TMDB antwortete mit {Code} auf {Pfad}.", (int)antwort.StatusCode, Gekürzt(adresse));
|
||||
return null;
|
||||
}
|
||||
|
||||
return await antwort.Content.ReadFromJsonSafeAsync<T>(Json, ct);
|
||||
}
|
||||
catch (MetadataCredentialsException) { throw; }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception fehler)
|
||||
{
|
||||
log.LogWarning(fehler, "TMDB war für {Pfad} nicht erreichbar.", Gekürzt(adresse));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Der Pfad ohne den Schlüssel — der gehört in kein Protokoll.</summary>
|
||||
private static string Gekürzt(string pfadMitAbfrage)
|
||||
{
|
||||
var index = pfadMitAbfrage.IndexOf("api_key=", StringComparison.Ordinal);
|
||||
return index < 0 ? pfadMitAbfrage : pfadMitAbfrage[..index] + "api_key=…";
|
||||
}
|
||||
|
||||
private static RemoteImage? Bild(string? pfad, string grösse) =>
|
||||
string.IsNullOrWhiteSpace(pfad) ? null : new RemoteImage($"https://image.tmdb.org/t/p/{grösse}{pfad}");
|
||||
|
||||
private static string? Leer(string? wert) => string.IsNullOrWhiteSpace(wert) ? null : wert;
|
||||
|
||||
private static int? JahrAus(string? datum) =>
|
||||
datum is { Length: >= 4 } && int.TryParse(datum[..4], out var jahr) ? jahr : null;
|
||||
|
||||
// Nur die Felder, die gebraucht werden. TMDB liefert dreissig weitere.
|
||||
private sealed record TmdbSearch<T>([property: JsonPropertyName("results")] List<T>? Results);
|
||||
|
||||
private sealed record TmdbExternalIds([property: JsonPropertyName("imdb_id")] string? ImdbId);
|
||||
|
||||
private sealed record TmdbMovie(
|
||||
[property: JsonPropertyName("id")] int Id,
|
||||
[property: JsonPropertyName("title")] string? Title,
|
||||
[property: JsonPropertyName("release_date")] string? ReleaseDate,
|
||||
[property: JsonPropertyName("overview")] string? Overview,
|
||||
[property: JsonPropertyName("poster_path")] string? PosterPath,
|
||||
[property: JsonPropertyName("backdrop_path")] string? BackdropPath);
|
||||
|
||||
private sealed record TmdbSeries(
|
||||
[property: JsonPropertyName("id")] int Id,
|
||||
[property: JsonPropertyName("name")] string? Name,
|
||||
[property: JsonPropertyName("first_air_date")] string? FirstAirDate,
|
||||
[property: JsonPropertyName("overview")] string? Overview,
|
||||
[property: JsonPropertyName("poster_path")] string? PosterPath,
|
||||
[property: JsonPropertyName("backdrop_path")] string? BackdropPath);
|
||||
|
||||
private sealed record TmdbEpisode(
|
||||
[property: JsonPropertyName("name")] string? Name,
|
||||
[property: JsonPropertyName("overview")] string? Overview,
|
||||
[property: JsonPropertyName("still_path")] string? StillPath);
|
||||
}
|
||||
|
||||
internal static class HttpContentExtensions
|
||||
{
|
||||
/// <summary>Liest JSON und wirft nicht, wenn Unsinn zurückkommt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Ein Reverse Proxy oder ein Anmeldeportal im Hotel-WLAN antwortet mit
|
||||
/// HTML und dem Status 200. Darauf mit einer Ausnahme zu reagieren hiesse,
|
||||
/// den ganzen Scan an einem fremden Netz scheitern zu lassen.
|
||||
/// </remarks>
|
||||
public static async Task<T?> ReadFromJsonSafeAsync<T>(
|
||||
this HttpContent inhalt, JsonSerializerOptions optionen, CancellationToken ct) where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var strom = await inhalt.ReadAsStreamAsync(ct);
|
||||
return await JsonSerializer.DeserializeAsync<T>(strom, optionen, ct);
|
||||
}
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user