diff --git a/LICENSES.md b/LICENSES.md index caa6164..025b544 100644 --- a/LICENSES.md +++ b/LICENSES.md @@ -32,3 +32,19 @@ aus diesem Grund NUnit (MIT) und nicht xUnit (Apache-2.0). **ffmpeg** wird als eigener Prozess aufgerufen und nicht eingebunden. Das hält die Lizenzen getrennt — und erlaubt nebenbei, die Argumente zu protokollieren. + +## TMDB + +Metadaten und Bilder kommen von [TMDB](https://www.themoviedb.org/). Deren +Nutzungsbedingungen verlangen zweierlei, und beides ist Bedingung, nicht +Höflichkeit: + +- Das TMDB-Logo muss sichtbar sein. +- Dieser Satz muss dastehen: *„This product uses TMDB and the TMDB APIs but is + not endorsed, certified, or otherwise approved by TMDB."* + +`GET /api/v1/system/info` liefert beides unter `attributions`, ohne Anmeldung. +**Jeder Client muss es anzeigen.** + +Erlaubt ist damit nur **nicht-kommerzielle** Nutzung. Geld dafür zu nehmen +braucht einen eigenen Vertrag mit TMDB. diff --git a/server/README.md b/server/README.md index f0f2cd5..92d349b 100644 --- a/server/README.md +++ b/server/README.md @@ -68,7 +68,7 @@ hilft. ## Entwickeln ```bash -dotnet test # 223 Tests +dotnet test # 238 Tests dotnet run --project src/Halo.Api # auf http://localhost:5000 ``` @@ -133,14 +133,27 @@ und ändert sich dabei. ### Metadaten -Ohne TMDB-Schlüssel läuft der Scan trotzdem durch; die Titel bleiben dann bei -den Dateinamen. Der Schlüssel wird unter `PUT /api/v1/settings/metadata` -hinterlegt und kommt nie wieder heraus — abgefragt wird nur, *ob* einer da ist. +**Halo bringt einen TMDB-Zugang mit** — es läuft ohne Einrichtung. Wer lieber +seinen eigenen benutzt, hinterlegt ihn unter `PUT /api/v1/settings/metadata`; +der sticht den mitgelieferten. Abgefragt werden kann nur, *ob* einer da ist und +ob es der mitgelieferte ist — der Zugang selbst kommt nie zurück. + +Hinterlegt werden kann beides, was auf TMDBs Kontoseite steht: der **API Read +Access Token** (empfohlen — er geht als `Authorization`-Kopfzeile hinaus, nicht +in der Adresse, und kann nur lesen) oder der ältere **API Key**. + +Die Sprache steht auf `en-US` und wird in denselben Einstellungen umgestellt. Bilder werden einmal heruntergeladen und liegen unter `HALO_CACHE`. Die App holt sie bei Halo, nicht bei TMDB: sonst bräuchte sie dafür Internet, und TMDB erführe, wer wann was ansieht. +**Der Hinweis auf TMDB ist Pflicht, keine Höflichkeit.** Deren Bedingungen +verlangen Logo und einen festen Satz. Beides liefert +`GET /api/v1/system/info` unter `attributions` — ohne Anmeldung abrufbar, +damit ein Client es auch auf dem Anmeldebildschirm zeigen kann. Jeder Client +muss es anzeigen. + ## Herunterladen — mit allen Spuren ``` diff --git a/server/src/Halo.Api/Endpoints/Contracts.cs b/server/src/Halo.Api/Endpoints/Contracts.cs index b35520d..6779239 100644 --- a/server/src/Halo.Api/Endpoints/Contracts.cs +++ b/server/src/Halo.Api/Endpoints/Contracts.cs @@ -4,7 +4,8 @@ namespace Halo.Api.Endpoints; // sonst wandert jede Umbenennung im Datenmodell ungefragt in die Schnittstelle // und bricht Clients, die niemand mehr aktualisiert. -public sealed record SystemInfo(string Version, bool NeedsSetup); +public sealed record SystemInfo( + string Version, bool NeedsSetup, IReadOnlyList Attributions); public sealed record SetupRequest(string UserName, string Password); @@ -87,7 +88,18 @@ public sealed record EpisodeResponse( public sealed record Page(IReadOnlyList Items, int Total); -public sealed record MetadataSettingsResponse(bool HasApiKey, string Language); +public sealed record MetadataSettingsResponse( + bool HasApiKey, string Language, bool UsingProjectKey); + +/// Was ein Client über der Mediathek anzeigen muss. +/// +/// +/// Bedingung, nicht Höflichkeit. TMDB verlangt in seinen +/// Nutzungsbedingungen, dass das Logo gezeigt wird und dieser Satz dasteht. +/// Der Server liefert beides, damit kein Client es abtippen muss — und keiner +/// es vergisst. +/// +public sealed record AttributionResponse(string Text, string LogoUrl, string Url); public sealed record MetadataSettingsRequest(string? ApiKey, string? Language); diff --git a/server/src/Halo.Api/Endpoints/SettingsEndpoints.cs b/server/src/Halo.Api/Endpoints/SettingsEndpoints.cs index bda99d8..f9ec169 100644 --- a/server/src/Halo.Api/Endpoints/SettingsEndpoints.cs +++ b/server/src/Halo.Api/Endpoints/SettingsEndpoints.cs @@ -18,9 +18,16 @@ public static class SettingsEndpoints // **Der Schlüssel selbst kommt nicht zurück.** Er gäbe jedem // Angemeldeten das TMDB-Kontingent des Betreibers in die Hand, und // zum Anzeigen genügt die Auskunft, dass einer da ist. + var aufgelöst = TmdbSettings.Resolve( + zeilen.GetValueOrDefault(DatabaseTmdbSettings.ApiKeyName), + zeilen.GetValueOrDefault(DatabaseTmdbSettings.LanguageName)); + return Results.Ok(new MetadataSettingsResponse( - HasApiKey: zeilen.GetValueOrDefault(DatabaseTmdbSettings.ApiKeyName) is { Length: > 0 }, - Language: zeilen.GetValueOrDefault(DatabaseTmdbSettings.LanguageName) ?? TmdbSettings.DefaultLanguage)); + HasApiKey: !string.IsNullOrEmpty(aufgelöst.ApiKey), + Language: aufgelöst.Language, + // Damit in der Oberfläche steht, ob der mitgelieferte Schlüssel + // benutzt wird — und dass ein eigener ihn ablösen würde. + UsingProjectKey: aufgelöst.UsingProjectKey)); }).RequireAuthorization(HaloPolicies.Administrator); app.MapPut("/api/v1/settings/metadata", async ( diff --git a/server/src/Halo.Api/Endpoints/SystemEndpoints.cs b/server/src/Halo.Api/Endpoints/SystemEndpoints.cs index 9ebdb36..5ef5811 100644 --- a/server/src/Halo.Api/Endpoints/SystemEndpoints.cs +++ b/server/src/Halo.Api/Endpoints/SystemEndpoints.cs @@ -1,4 +1,5 @@ using Halo.Core.Users; +using Halo.Metadata; using Halo.Data; using Microsoft.EntityFrameworkCore; @@ -20,7 +21,13 @@ public static class SystemEndpoints app.MapGet("/api/v1/system/info", async (HaloContext db) => Results.Ok(new SystemInfo( Version: typeof(SystemEndpoints).Assembly.GetName().Version?.ToString(3) ?? "0.0.0", - NeedsSetup: !await db.Users.AnyAsync()))) + NeedsSetup: !await db.Users.AnyAsync(), + // Ohne Anmeldung abrufbar: ein Client muss den Hinweis auch + // auf dem Anmeldebildschirm zeigen können. + Attributions: + [ + new AttributionResponse(Attribution.Tmdb, Attribution.TmdbLogoUrl, Attribution.TmdbUrl), + ]))) .AllowAnonymous(); app.MapPost("/api/v1/system/setup", async (SetupRequest anfrage, HaloContext db) => diff --git a/server/src/Halo.Api/MetadataSettings.cs b/server/src/Halo.Api/MetadataSettings.cs index b08a8ae..a13edd9 100644 --- a/server/src/Halo.Api/MetadataSettings.cs +++ b/server/src/Halo.Api/MetadataSettings.cs @@ -30,10 +30,8 @@ public sealed class DatabaseTmdbSettings(HaloContext db) : ITmdbSettingsSource .Where(s => s.Key == ApiKeyName || s.Key == LanguageName) .ToDictionaryAsync(s => s.Key, s => s.Value, ct); - return _gemerkt = new TmdbSettings( + return _gemerkt = TmdbSettings.Resolve( zeilen.GetValueOrDefault(ApiKeyName), - zeilen.GetValueOrDefault(LanguageName) is { Length: > 0 } sprache - ? sprache - : TmdbSettings.DefaultLanguage); + zeilen.GetValueOrDefault(LanguageName)); } } diff --git a/server/src/Halo.Metadata/Attribution.cs b/server/src/Halo.Metadata/Attribution.cs new file mode 100644 index 0000000..d26a229 --- /dev/null +++ b/server/src/Halo.Metadata/Attribution.cs @@ -0,0 +1,24 @@ +namespace Halo.Metadata; + +/// Was TMDB verlangt, wenn man ihre Daten benutzt. +/// +/// +/// Keine Höflichkeit, sondern Bedingung. 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. +/// +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/"; +} diff --git a/server/src/Halo.Metadata/TmdbClient.cs b/server/src/Halo.Metadata/TmdbClient.cs index 4d6ba1a..0766bc7 100644 --- a/server/src/Halo.Metadata/TmdbClient.cs +++ b/server/src/Halo.Metadata/TmdbClient.cs @@ -41,13 +41,23 @@ public sealed class TmdbClient( private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + /// Ein Lese-Token ist ein JWT und fängt immer so an. + /// + /// + /// Damit beides funktioniert, was auf TMDBs Kontoseite steht. 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. + /// + private const string JwtAnfang = "eyJ"; + public async Task IsConfiguredAsync(CancellationToken ct = default) => !string.IsNullOrWhiteSpace((await einstellungen.GetAsync(ct)).ApiKey); public async Task FindMovieAsync(string title, int? year, CancellationToken ct = default) { var treffer = await SuchenAsync("search/movie", title, - year is { } j ? ("year", j.ToString()) : null, ct); + year is { } j ? ("year", j.ToString()) : null, m => m.Title, ct); if (treffer is null) return null; return new MovieMetadata( @@ -62,7 +72,7 @@ public sealed class TmdbClient( public async Task FindSeriesAsync(string title, int? year, CancellationToken ct = default) { var treffer = await SuchenAsync("search/tv", title, - year is { } j ? ("first_air_date_year", j.ToString()) : null, ct); + year is { } j ? ("first_air_date_year", j.ToString()) : null, s => s.Name, ct); if (treffer is null) return null; return new SeriesMetadata( @@ -81,40 +91,73 @@ public sealed class TmdbClient( if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null; var pfad = $"tv/{seriesProviderId}/season/{season}/episode/{episode}" + - $"?api_key={Uri.EscapeDataString(einstellung.ApiKey)}" + - $"&language={Uri.EscapeDataString(einstellung.Language)}"; + $"?language={Uri.EscapeDataString(einstellung.Language)}"; - var folge = await HolenAsync(pfad, ct); + var folge = await HolenAsync(pfad, einstellung, ct); if (folge is null) return null; return new EpisodeMetadata(Leer(folge.Name), Leer(folge.Overview), Bild(folge.StillPath, StillSize)); } private async Task SuchenAsync( - string pfad, string titel, (string Name, string Wert)? zusatz, CancellationToken ct) where T : class + string pfad, string titel, (string Name, string Wert)? zusatz, + Func 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}?api_key={Uri.EscapeDataString(einstellung.ApiKey)}" + - $"&language={Uri.EscapeDataString(einstellung.Language)}" + + 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>(abfrage, ct); + var antwort = await HolenAsync>(abfrage, einstellung, ct); + var treffer = antwort?.Results; + if (treffer is null || treffer.Count == 0) return null; - // **Der erste Treffer, nicht der beste.** TMDB sortiert nach - // Beliebtheit, und das ist für einen Titel mit Jahr fast immer richtig. - // Eine eigene Bewertung zu bauen hiesse, TMDBs Rangfolge nachzubauen — - // mit weniger Daten. - return antwort?.Results?.FirstOrDefault(); + // **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]; } - private async Task HolenAsync(string pfadMitAbfrage, CancellationToken ct) where T : class + /// Holt eine Antwort — mit Bearer, wo es geht. + /// + /// + /// Der Lese-Token wandert in die Kopfzeile, nicht in die Adresse. + /// Ein ?api_key=… 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. + /// + private async Task HolenAsync( + 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 antwort = await http.GetAsync(pfadMitAbfrage, ct); + 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( @@ -126,7 +169,7 @@ public sealed class TmdbClient( if (!antwort.IsSuccessStatusCode) { - log.LogWarning("TMDB antwortete mit {Code} auf {Pfad}.", (int)antwort.StatusCode, Gekürzt(pfadMitAbfrage)); + log.LogWarning("TMDB antwortete mit {Code} auf {Pfad}.", (int)antwort.StatusCode, Gekürzt(adresse)); return null; } @@ -136,7 +179,7 @@ public sealed class TmdbClient( catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception fehler) { - log.LogWarning(fehler, "TMDB war für {Pfad} nicht erreichbar.", Gekürzt(pfadMitAbfrage)); + log.LogWarning(fehler, "TMDB war für {Pfad} nicht erreichbar.", Gekürzt(adresse)); return null; } } diff --git a/server/src/Halo.Metadata/TmdbSettings.cs b/server/src/Halo.Metadata/TmdbSettings.cs index 4923243..2292a56 100644 --- a/server/src/Halo.Metadata/TmdbSettings.cs +++ b/server/src/Halo.Metadata/TmdbSettings.cs @@ -1,7 +1,7 @@ namespace Halo.Metadata; /// Was TMDB braucht, um zu antworten. -public sealed record TmdbSettings(string? ApiKey, string Language) +public sealed record TmdbSettings(string? ApiKey, string Language, bool UsingProjectKey = false) { /// Englisch als Voreinstellung, weil Halo nicht nur hier läuft. /// @@ -12,7 +12,44 @@ public sealed record TmdbSettings(string? ApiKey, string Language) /// public const string DefaultLanguage = "en-US"; + /// Der mitgelieferte Schlüssel des Projekts. + /// + /// + /// + /// Hier gehört der Schlüssel hinein, den Halo ausliefert. Ist er + /// leer, verlangt Halo von jedem Betreiber einen eigenen — die Mediathek + /// läuft dann trotzdem, nur mit Dateinamen statt Titeln. + /// + /// + /// Er lässt sich nicht geheim halten, und das ist bekannt. In einem + /// offenen Projekt steht er im Quellcode oder im Image; beides kann jeder + /// lesen. Jellyfin macht es genauso — deren Konstante steht offen in + /// TmdbUtils.cs. 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. + /// + /// + /// Deshalb gibt es den Ausweg darüber: wer seinen eigenen Schlüssel + /// in den Einstellungen hinterlegt, benutzt den. Würde der gemeinsame je + /// gesperrt, steht damit nicht jede Installation still. + /// + /// + public const string ProjectApiKey = + "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJjODFlMDQ4MTFlYTBkODFjM2E5OGY5NjM5ZTA1OTRhZiIsIm5iZiI6MTc2NjU2Mjg3Ni4zNTgsInN1YiI6IjY5NGI5YzNjMzliY2I1YmZmOGIxZDY5MSIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.CAqa5bRzJtMLNkQMJDARb9Hny3LTwssqWvCGdrDIzSA"; + public static readonly TmdbSettings Unconfigured = new(null, DefaultLanguage); + + /// Der eigene Schlüssel sticht den mitgelieferten. + 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); + } } /// Woher die Einstellungen kommen. diff --git a/server/tests/Halo.Api.Tests/AbgeschalteterAnbieter.cs b/server/tests/Halo.Api.Tests/AbgeschalteterAnbieter.cs new file mode 100644 index 0000000..7400681 --- /dev/null +++ b/server/tests/Halo.Api.Tests/AbgeschalteterAnbieter.cs @@ -0,0 +1,24 @@ +using Halo.Core.Metadata; + +namespace Halo.Api.Tests; + +/// Ein Anbieter, der nichts weiss und nichts fragt. +/// +/// +/// Die Voreinstellung für alle Tests, die sich nicht um Metadaten kümmern. +/// Ohne ihn liefe bei jedem Scan eine echte Anfrage hinaus — und ein Projekt, +/// dessen Tests einen fremden Dienst brauchen, hat am Ende keine Tests. +/// +public sealed class AbgeschalteterAnbieter : IMetadataProvider +{ + public Task IsConfiguredAsync(CancellationToken ct = default) => Task.FromResult(false); + + public Task FindMovieAsync(string t, int? j, CancellationToken ct = default) => + throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden."); + + public Task FindSeriesAsync(string t, int? j, CancellationToken ct = default) => + throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden."); + + public Task FindEpisodeAsync(int s, int st, int f, CancellationToken ct = default) => + throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden."); +} diff --git a/server/tests/Halo.Api.Tests/HaloServer.cs b/server/tests/Halo.Api.Tests/HaloServer.cs index 0e07c67..3eab9b8 100644 --- a/server/tests/Halo.Api.Tests/HaloServer.cs +++ b/server/tests/Halo.Api.Tests/HaloServer.cs @@ -4,6 +4,7 @@ using Halo.Api.Endpoints; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Halo.Core.Metadata; namespace Halo.Api.Tests; @@ -43,6 +44,17 @@ public sealed class HaloServer : IDisposable // als sämtliche Prüfungen zusammen brauchen. Hier gibt es nichts, // worauf zu warten wäre. sammlung.Configure(o => o.ShutdownTimeout = TimeSpan.FromMilliseconds(50)); + + // **Keine Anfragen nach draussen, solange ein Test es nicht + // ausdrücklich will.** Seit ein Schlüssel mitgeliefert wird, würde + // jeder Scan sonst wirklich bei TMDB nachfragen: die Tests hingen + // am WLAN, und ihre Erwartungen an fremden Daten. „Der Untergang" + // hiess prompt „Downfall", weil die Voreinstellung Englisch ist. + // + // Vor `dienste`, damit ein Test seinen eigenen Anbieter + // nachschieben kann — die spätere Eintragung gewinnt. + sammlung.AddScoped(); + dienste?.Invoke(sammlung); })); Client = _fabrik.CreateClient(); diff --git a/server/tests/Halo.Api.Tests/MetadataTests.cs b/server/tests/Halo.Api.Tests/MetadataTests.cs index f198b99..898e1b9 100644 --- a/server/tests/Halo.Api.Tests/MetadataTests.cs +++ b/server/tests/Halo.Api.Tests/MetadataTests.cs @@ -272,18 +272,54 @@ public class MetadataSettingsTests Assert.That(stand.Language, Is.EqualTo("de-DE")); } + /// Ohne eigenen Schlüssel greift der mitgelieferte. + /// + /// + /// UsingProjectKey ist der Unterschied, den die Oberfläche zeigen + /// muss: „es läuft, aber über das gemeinsame Kontingent" ist etwas anderes + /// als „du hast einen eigenen hinterlegt". + /// [Test] - public async Task Ohne_Schluessel_sagt_der_Server_das_auch() + public async Task Ohne_eigenen_Schluessel_greift_der_mitgelieferte() { using var server = new HaloServer(); var admin = await server.SetUpAndSignIn(); var stand = await server.AlsAngemeldeter(admin.Token) .GetFromJsonAsync("/api/v1/settings/metadata"); - Assert.That(stand!.HasApiKey, Is.False); + var mitgeliefert = Halo.Metadata.TmdbSettings.ProjectApiKey.Length > 0; + Assert.That(stand!.HasApiKey, Is.EqualTo(mitgeliefert)); + Assert.That(stand.UsingProjectKey, Is.EqualTo(mitgeliefert)); Assert.That(stand.Language, Is.EqualTo("en-US")); } + [Test] + public async Task Ein_eigener_Schluessel_loest_den_mitgelieferten_ab() + { + using var server = new HaloServer(); + var admin = await server.SetUpAndSignIn(); + var client = server.AlsAngemeldeter(admin.Token); + + await client.PutAsJsonAsync("/api/v1/settings/metadata", + new MetadataSettingsRequest("mein-eigener-schluessel", "de-DE")); + + var stand = await client.GetFromJsonAsync("/api/v1/settings/metadata"); + Assert.That(stand!.HasApiKey, Is.True); + Assert.That(stand.UsingProjectKey, Is.False); + } + + /// TMDB verlangt den Hinweis — und zwar ohne Anmeldung abrufbar. + [Test] + public async Task Der_Pflichthinweis_zu_TMDB_steht_in_der_Systemauskunft() + { + using var server = new HaloServer(); + var info = await server.Client.GetFromJsonAsync("/api/v1/system/info"); + + var tmdb = info!.Attributions.Single(); + Assert.That(tmdb.Text, Does.Contain("TMDB").And.Contain("not endorsed")); + Assert.That(tmdb.LogoUrl, Does.StartWith("https://")); + } + [TestCase("deutsch")] [TestCase("de_DE")] [TestCase("DE")] diff --git a/server/tests/Halo.Metadata.Tests/Attrappe.cs b/server/tests/Halo.Metadata.Tests/Attrappe.cs index 4d7276c..89decde 100644 --- a/server/tests/Halo.Metadata.Tests/Attrappe.cs +++ b/server/tests/Halo.Metadata.Tests/Attrappe.cs @@ -15,12 +15,16 @@ public sealed class Attrappe(Func antwo { public List Aufrufe { get; } = []; + /// Die Authorization-Kopfzeile je Aufruf — oder null. + public List Kopfzeilen { get; } = []; + protected override Task SendAsync(HttpRequestMessage anfrage, CancellationToken ct) { // AbsoluteUri und nicht ToString(): Letzteres entschlüsselt zur // Anzeige wieder, und dann sähe eine richtig kodierte Anfrage falsch // aus — der Test prüfte eine Darstellung statt dessen, was hinausgeht. Aufrufe.Add(anfrage.RequestUri!.AbsoluteUri); + Kopfzeilen.Add(anfrage.Headers.Authorization?.ToString()); return Task.FromResult(antwort(anfrage)); } diff --git a/server/tests/Halo.Metadata.Tests/TmdbClientTests.cs b/server/tests/Halo.Metadata.Tests/TmdbClientTests.cs index fb29970..573fe5f 100644 --- a/server/tests/Halo.Metadata.Tests/TmdbClientTests.cs +++ b/server/tests/Halo.Metadata.Tests/TmdbClientTests.cs @@ -161,3 +161,62 @@ public class TmdbClientTests Assert.That(film.Poster, Is.Null); } } + +/// Welcher Treffer gewinnt, wenn mehrere in Frage kommen. +public class TrefferwahlTests +{ + private static TmdbClient Client(Attrappe attrappe) => + new(new HttpClient(attrappe) { BaseAddress = new Uri(TmdbClient.BaseAddress) }, + FesteEinstellungen.MitSchluessel(), NullLogger.Instance); + + /// Am echten Dienst gemessen, nicht vermutet. + /// + /// + /// Die Suche nach „Dark" lieferte „Dark Shadows" von 1966. TMDB + /// sortiert nach Beliebtheit, und ohne Jahresangabe führt das in die Irre. + /// Ein Treffer mit genau diesem Namen ist gemeint, auch wenn er weiter + /// unten steht. + /// + [Test] + public async Task Ein_genau_passender_Name_sticht_den_beliebteren() + { + var attrappe = Attrappe.MitJson(""" + {"results":[ + {"id":1,"name":"Dark Shadows","first_air_date":"1966-06-27"}, + {"id":2,"name":"Dark","first_air_date":"2017-12-01"} + ]} + """); + + var serie = await Client(attrappe).FindSeriesAsync("Dark", null); + Assert.That(serie!.ProviderId, Is.EqualTo(2)); + Assert.That(serie.Year, Is.EqualTo(2017)); + } + + [Test] + public async Task Gross_und_Kleinschreibung_spielt_dabei_keine_Rolle() + { + var attrappe = Attrappe.MitJson(""" + {"results":[{"id":1,"title":"Irgendwas"},{"id":2,"title":"DER UNTERGANG"}]} + """); + + var film = await Client(attrappe).FindMovieAsync("Der Untergang", null); + Assert.That(film!.ProviderId, Is.EqualTo(2)); + } + + /// Ohne genaue Entsprechung bleibt es beim beliebtesten. + /// + /// + /// Eine eigene Ähnlichkeitsbewertung zu bauen hiesse, TMDBs Rangfolge + /// nachzubauen — mit weniger Daten. + /// + [Test] + public async Task Ohne_genaue_Entsprechung_gewinnt_der_erste() + { + var attrappe = Attrappe.MitJson(""" + {"results":[{"id":1,"title":"Der Untergang des Hauses Usher"},{"id":2,"title":"Untergang"}]} + """); + + var film = await Client(attrappe).FindMovieAsync("Der Untergang", null); + Assert.That(film!.ProviderId, Is.EqualTo(1)); + } +} diff --git a/server/tests/Halo.Metadata.Tests/ZugangTests.cs b/server/tests/Halo.Metadata.Tests/ZugangTests.cs new file mode 100644 index 0000000..25437f8 --- /dev/null +++ b/server/tests/Halo.Metadata.Tests/ZugangTests.cs @@ -0,0 +1,125 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace Halo.Metadata.Tests; + +/// Womit sich Halo bei TMDB ausweist. +public class ZugangTests +{ + private const string LeseToken = "eyJhbGciOiJIUzI1NiJ9.beispiel.unterschrift"; + private const string AelterSchluessel = "0123456789abcdef0123456789abcdef"; + + private static (TmdbClient Client, Attrappe Attrappe) Mit(string zugang) + { + var attrappe = Attrappe.MitJson("""{"results":[{"id":1,"title":"Egal"}]}"""); + return (new TmdbClient( + new HttpClient(attrappe) { BaseAddress = new Uri(TmdbClient.BaseAddress) }, + new FesteEinstellungen(new TmdbSettings(zugang, "de-DE")), + NullLogger.Instance), attrappe); + } + + /// Ein Lese-Token gehört in die Kopfzeile, nicht in die Adresse. + /// + /// + /// Eine Adresse steht in jedem Zugriffsprotokoll. In jedem Reverse + /// Proxy, in jeder Fehlermeldung, die eine URL zitiert. Eine Kopfzeile tut + /// das nicht — und bei einem Schlüssel, der in einem offenen Projekt + /// mitgeliefert wird, zählt jede Stelle, an der er nicht auftaucht. + /// + [Test] + public async Task Der_Lese_Token_geht_als_Bearer_und_nicht_in_die_Adresse() + { + var (client, attrappe) = Mit(LeseToken); + await client.FindMovieAsync("Egal", null); + + Assert.That(attrappe.Kopfzeilen.Single(), Is.EqualTo($"Bearer {LeseToken}")); + Assert.That(attrappe.Aufrufe.Single(), Does.Not.Contain("api_key")); + Assert.That(attrappe.Aufrufe.Single(), Does.Not.Contain(LeseToken)); + } + + /// Der ältere Schlüssel kann nur in die Adresse — also tut er das. + [Test] + public async Task Der_aeltere_Schluessel_geht_weiterhin_als_Abfrageteil() + { + var (client, attrappe) = Mit(AelterSchluessel); + await client.FindMovieAsync("Egal", null); + + Assert.That(attrappe.Kopfzeilen.Single(), Is.Null); + Assert.That(attrappe.Aufrufe.Single(), Does.Contain($"api_key={AelterSchluessel}")); + } + + [Test] + public async Task Auch_bei_Folgen_wird_der_Bearer_mitgeschickt() + { + var (client, attrappe) = Mit(LeseToken); + await client.FindEpisodeAsync(70523, 1, 1); + + Assert.That(attrappe.Kopfzeilen.Single(), Does.StartWith("Bearer ")); + Assert.That(attrappe.Aufrufe.Single(), Does.Not.Contain("api_key")); + } +} + +/// Welcher Schlüssel gilt, wenn mehrere in Frage kommen. +public class SchluesselwahlTests +{ + [Test] + public void Ein_eigener_Schluessel_sticht_den_mitgelieferten() + { + var gewählt = TmdbSettings.Resolve("mein-eigener", "de-DE"); + + Assert.That(gewählt.ApiKey, Is.EqualTo("mein-eigener")); + Assert.That(gewählt.UsingProjectKey, Is.False); + } + + [Test] + public void Ohne_eigenen_greift_der_mitgelieferte() + { + var gewählt = TmdbSettings.Resolve(null, null); + + if (TmdbSettings.ProjectApiKey.Length == 0) + { + Assert.That(gewählt.ApiKey, Is.Null, "Ohne mitgelieferten Schlüssel bleibt es dabei"); + Assert.That(gewählt.UsingProjectKey, Is.False); + return; + } + + Assert.That(gewählt.ApiKey, Is.EqualTo(TmdbSettings.ProjectApiKey)); + Assert.That(gewählt.UsingProjectKey, Is.True); + } + + [TestCase("")] + [TestCase(" ")] + public void Ein_leerer_eigener_Schluessel_zaehlt_als_keiner(string leer) + { + Assert.That(TmdbSettings.Resolve(leer, null).UsingProjectKey, + Is.EqualTo(TmdbSettings.ProjectApiKey.Length > 0)); + } + + [Test] + public void Leerraum_um_den_Schluessel_wird_abgeschnitten() + { + Assert.That(TmdbSettings.Resolve(" meiner ", " de-DE ").ApiKey, Is.EqualTo("meiner")); + Assert.That(TmdbSettings.Resolve("meiner", " de-DE ").Language, Is.EqualTo("de-DE")); + } + + [Test] + public void Ohne_Sprachangabe_gilt_Englisch() + { + Assert.That(TmdbSettings.Resolve("meiner", null).Language, Is.EqualTo("en-US")); + } + + /// Der mitgelieferte Schlüssel darf nur lesen dürfen. + /// + /// + /// Er steht öffentlich im Quellcode. Ein Lese-Token kann nichts ändern — + /// ein älterer API-Schlüssel schon. Diese Prüfung hält fest, dass hier + /// keiner der falschen Art hineingerät. + /// + [Test] + public void Der_mitgelieferte_Schluessel_ist_ein_Lese_Token() + { + if (TmdbSettings.ProjectApiKey.Length == 0) Assert.Pass("Kein Schlüssel mitgeliefert."); + + Assert.That(TmdbSettings.ProjectApiKey, Does.StartWith("eyJ"), + "Erwartet wird ein API Read Access Token, kein älterer API-Schlüssel"); + } +}