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:
Scarriffle
2026-10-03 19:38:27 +02:00
co-authored by Claude Opus 5
parent f98b995050
commit a7cf6c5def
390 changed files with 2078 additions and 2069 deletions
+24
View File
@@ -0,0 +1,24 @@
namespace Flaro.Metadata;
/// <summary>Was TMDB verlangt, wenn man ihre Daten benutzt.</summary>
///
/// <remarks>
/// <b>Keine Höflichkeit, sondern Bedingung.</b> Die Nutzungsbedingungen von
/// TMDB verlangen wörtlich, dass ihr Logo gezeigt wird und dass dieser Satz
/// dasteht. Wer Metadaten von dort nimmt und das weglässt, verstösst gegen
/// die Bedingungen — unabhängig davon, wessen Schlüssel benutzt wird.
///
/// Der Text steht hier und nicht in der Oberfläche, damit jeder Client ihn
/// abrufen kann und keiner ihn abtippen muss.
/// </remarks>
public static class Attribution
{
public const string Tmdb =
"This product uses TMDB and the TMDB APIs but is not endorsed, certified, " +
"or otherwise approved by TMDB.";
public const string TmdbLogoUrl =
"https://www.themoviedb.org/assets/2/v4/logos/v2/blue_square_2-d537fb228cf3ded904ef09b136fe3fec72548ebc1fea3fbbd1ad9e36364db38b.svg";
public const string TmdbUrl = "https://www.themoviedb.org/";
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Flaro.Core\Flaro.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.12" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+115
View File
@@ -0,0 +1,115 @@
using System.Security.Cryptography;
using System.Text;
using Flaro.Core.Metadata;
using Microsoft.Extensions.Logging;
namespace Flaro.Metadata;
public sealed record ImageCacheOptions(string Root);
/// <summary>Lädt Bilder einmal herunter und behält sie.</summary>
///
/// <remarks>
/// <para>
/// <b>Der Schlüssel ist der Hash der Quelladresse.</b> Damit lädt dasselbe
/// Plakat nie zweimal — und zwei Serien, die sich ein Bild teilen, belegen es
/// einmal. Nebenbei enthält der Schlüssel nichts, woraus sich etwas ableiten
/// liesse.
/// </para>
/// <para>
/// <b>Abgelegt wird in Unterordnern nach den ersten zwei Zeichen.</b> Ein
/// Bestand mit zehntausend Titeln ergibt zwanzigtausend Bilder, und ein
/// Verzeichnis mit zwanzigtausend Einträgen bringt manche Dateisysteme — und
/// jedes `ls` darauf — spürbar ins Stocken.
/// </para>
/// </remarks>
public sealed class ImageCache(
HttpClient http,
ImageCacheOptions optionen,
ILogger<ImageCache> log) : IImageStore, IImageReader
{
/// <summary>Mehr als das ist kein Plakat, sondern ein Versehen.</summary>
private const long HöchstensBytes = 16 * 1024 * 1024;
public async Task<string?> StoreAsync(RemoteImage bild, CancellationToken ct = default)
{
var schlüssel = Schlüssel(bild.Url);
var ziel = PfadFür(schlüssel);
if (ziel is null) return null;
if (File.Exists(ziel)) return schlüssel;
try
{
using var antwort = await http.GetAsync(bild.Url, HttpCompletionOption.ResponseHeadersRead, ct);
if (!antwort.IsSuccessStatusCode)
{
log.LogWarning("Bild {Adresse} kam mit {Code} zurück.", bild.Url, (int)antwort.StatusCode);
return null;
}
if (antwort.Content.Headers.ContentLength > HöchstensBytes)
{
log.LogWarning("Bild {Adresse} ist grösser als erlaubt und wird übergangen.", bild.Url);
return null;
}
Directory.CreateDirectory(Path.GetDirectoryName(ziel)!);
// Erst daneben schreiben, dann umbenennen. Bricht der Download ab,
// liegt sonst eine halbe Datei unter dem richtigen Namen — und die
// wird nie wieder nachgeladen, weil der Schlüssel ja vorhanden ist.
var halbfertig = ziel + ".teil";
await using (var datei = File.Create(halbfertig))
{
await antwort.Content.CopyToAsync(datei, ct);
}
File.Move(halbfertig, ziel, overwrite: true);
return schlüssel;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception fehler)
{
log.LogWarning(fehler, "Bild {Adresse} liess sich nicht ablegen.", bild.Url);
return null;
}
}
/// <summary>Wo ein Schlüssel liegt — oder <c>null</c>, wenn er keiner ist.</summary>
///
/// <remarks>
/// <b>Die Prüfung ist kein Schmuck.</b> Der Schlüssel kommt aus einer
/// Adresszeile. Ohne sie wäre <c>/api/v1/images/..%2f..%2fconfig%2fflaro.db</c>
/// ein Weg, die Datenbank herunterzuladen — samt aller Token-Fingerabdrücke.
/// </remarks>
public string? PfadFür(string schlüssel)
{
if (schlüssel.Length != 64 || !schlüssel.All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f'))
return null;
return Path.Combine(optionen.Root, "images", schlüssel[..2], schlüssel + ".img");
}
/// <summary>Gibt ein abgelegtes Bild zum Lesen heraus.</summary>
///
/// <remarks>
/// Damit dasselbe Plakat zusätzlich in den Medienordner gelegt werden
/// kann, ohne es ein zweites Mal herunterzuladen.
/// </remarks>
public Task<Stream?> OpenAsync(string schlüssel, CancellationToken ct = default)
{
var pfad = PfadFür(schlüssel);
if (pfad is null || !File.Exists(pfad)) return Task.FromResult<Stream?>(null);
try { return Task.FromResult<Stream?>(File.OpenRead(pfad)); }
catch (IOException fehler)
{
log.LogDebug(fehler, "{Pfad} liess sich nicht öffnen.", pfad);
return Task.FromResult<Stream?>(null);
}
}
private static string Schlüssel(string adresse) =>
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(adresse)));
}
+318
View File
@@ -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; }
}
}
+64
View File
@@ -0,0 +1,64 @@
namespace Flaro.Metadata;
/// <summary>Was TMDB braucht, um zu antworten.</summary>
public sealed record TmdbSettings(string? ApiKey, string Language, bool UsingProjectKey = false)
{
/// <summary>Englisch als Voreinstellung, weil Flaro nicht nur hier läuft.</summary>
///
/// <remarks>
/// Der Betreiber stellt in der Oberfläche um. Eine deutsche Voreinstellung
/// wäre bequem für genau einen Bestand und für jeden anderen eine
/// Überraschung.
/// </remarks>
public const string DefaultLanguage = "en-US";
/// <summary>Der mitgelieferte Schlüssel des Projekts.</summary>
///
/// <remarks>
/// <para>
/// <b>Hier gehört der Schlüssel hinein, den Flaro ausliefert.</b> Ist er
/// leer, verlangt Flaro von jedem Betreiber einen eigenen — die Mediathek
/// läuft dann trotzdem, nur mit Dateinamen statt Titeln.
/// </para>
/// <para>
/// <b>Er lässt sich nicht geheim halten, und das ist bekannt.</b> In einem
/// offenen Projekt steht er im Quellcode oder im Image; beides kann jeder
/// lesen. Jellyfin macht es genauso — deren Konstante steht offen in
/// <c>TmdbUtils.cs</c>. TMDBs Bedingungen sagen zum Teilen von Schlüsseln
/// nichts, weder erlaubt noch verboten; ein Moderator hat im Forum nur
/// darauf hingewiesen, dass Missbrauch zur Sperrung führt.
/// </para>
/// <para>
/// <b>Deshalb gibt es den Ausweg darüber:</b> wer seinen eigenen Schlüssel
/// in den Einstellungen hinterlegt, benutzt den. Würde der gemeinsame je
/// gesperrt, steht damit nicht jede Installation still.
/// </para>
/// </remarks>
public const string ProjectApiKey =
"eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJjODFlMDQ4MTFlYTBkODFjM2E5OGY5NjM5ZTA1OTRhZiIsIm5iZiI6MTc2NjU2Mjg3Ni4zNTgsInN1YiI6IjY5NGI5YzNjMzliY2I1YmZmOGIxZDY5MSIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.CAqa5bRzJtMLNkQMJDARb9Hny3LTwssqWvCGdrDIzSA";
public static readonly TmdbSettings Unconfigured = new(null, DefaultLanguage);
/// <summary>Der eigene Schlüssel sticht den mitgelieferten.</summary>
public static TmdbSettings Resolve(string? eigener, string? sprache)
{
var gewählt = string.IsNullOrWhiteSpace(eigener) ? null : eigener.Trim();
var ausProjekt = gewählt is null && !string.IsNullOrWhiteSpace(ProjectApiKey);
return new TmdbSettings(
ApiKey: gewählt ?? (ausProjekt ? ProjectApiKey : null),
Language: string.IsNullOrWhiteSpace(sprache) ? DefaultLanguage : sprache.Trim(),
UsingProjectKey: ausProjekt);
}
}
/// <summary>Woher die Einstellungen kommen.</summary>
///
/// <remarks>
/// Eine Schnittstelle, damit der Client gegen feste Werte geprüft werden kann,
/// ohne dass eine Datenbank dahinterstehen muss.
/// </remarks>
public interface ITmdbSettingsSource
{
Task<TmdbSettings> GetAsync(CancellationToken ct = default);
}