Files
Flaro/server/tests/Halo.Metadata.Tests/TmdbClientTests.cs
T
ScarriffleandClaude Opus 5 f98b995050 Beiwerk im Medienordner, und eine Zuordnung lässt sich korrigieren
Plakate, Hintergründe und NFO-Dateien landen jetzt neben dem Film — und eine
falsche Zuordnung lässt sich richtigstellen, ohne dass der nächste Scan sie
wieder überschreibt.

    Der Untergang (2004)/
    ├── Der Untergang (2004).mkv
    ├── poster.jpg
    ├── fanart.jpg
    └── movie.nfo

Bei Serien liegt `tvshow.nfo` eine Ebene über dem Staffelordner, je Folge
kommen NFO und Standbild daneben. Liegen mehrere Filme in einem Ordner, heisst
das Beiwerk `Titel (Jahr)-poster.jpg` — sonst überschrieben sie sich
gegenseitig.

**Die Erlaubnisliste ist der ganze Schutz.** Damit das geht, muss die
Medieneinhängung ohne `:ro` auskommen, und damit könnte der Server dort alles
anstellen. Dass er es nicht tut, hängt an `SidecarNames`: sechs Arten von
Datei, nichts sonst. Ein Name, der nicht daraufsteht, wirft — laut, nicht
still, denn das wäre ein Fehler im Programm. Mediendateien werden nie
geändert, umbenannt oder gelöscht; es gibt keine Stelle, die das könnte.
Geprüft an echten Dateien, einschliesslich der Versuche mit `../`, mit
absoluten Pfaden und mit einer Mediendatei als Ziel.

Standardmässig **aus**. In fremde Medienordner zu schreiben ist nichts, was
ein Server ungefragt tun sollte.

**Gelesen wird auch ohne Schreiberlaubnis.** Eine vorhandene NFO ist eine
Zuordnung, die jemand getroffen hat — Halo nimmt sie und sucht gar nicht
erst. Dasselbe gilt für Ordnernamen der Form `[tmdbid-613]`. Halo liest sie
und benennt **nichts** um: Umbenennen ist der einzige Eingriff, der eine
Bibliothek wirklich zerlegen kann.

Nebenbei: eine NFO aus dem Medienordner hat Halo nicht geschrieben. DTDs sind
beim Lesen abgeschaltet — eine untergeschobene Datei mit externer Entität
könnte sonst den Server dazu bringen, `/config/halo.db` einzulesen und als
Titel auszuliefern. Mit Prüfung.

---

**Der Fehler, den erst der echte Lauf zeigte** — und er ist die zweite
Auflage eines schon einmal behobenen:

Nach der Korrektur stand im Ordner „Dark (2017)", in der Datenbank aber 2004.
Der nächste Scan suchte nach *(Dark, 2017)*, fand nichts, legte den Film neu
an und löschte den korrigierten als verwaist. Überlebt hat die Zuordnung nur,
weil sie in der NFO stand — das System hat sich selbst gerettet, aber der
Eintrag war ein anderer und das Jahr wieder falsch.

Dieselbe Klasse wie bei `ParsedTitle`: zugeordnet wurde über einen Wert, den
die Metadatenquelle verändert. Jetzt gibt es `ParsedYear`, und das ändert sich
nie. Mit Prüfung, die genau diesen Ablauf festhält.

---

Dazu von TMDB: die IMDb-Kennung wird nachgeschlagen, damit sie in der NFO
steht — wer später auf ein anderes Werkzeug wechselt, findet denselben Titel
wieder, auch wenn das TMDB gar nicht kennt.

308 Tests grün. Im Container nachgewiesen: Zuordnung falsch, korrigiert,
zweiter Scan — derselbe Eintrag, dieselbe Kennung, Jahr bleibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 19:01:26 +02:00

225 lines
8.9 KiB
C#

using System.Net;
using Halo.Core.Metadata;
using Microsoft.Extensions.Logging.Abstractions;
namespace Halo.Metadata.Tests;
public class TmdbClientTests
{
private static TmdbClient Client(Attrappe attrappe, ITmdbSettingsSource? einstellungen = null) =>
new(new HttpClient(attrappe) { BaseAddress = new Uri(TmdbClient.BaseAddress) },
einstellungen ?? FesteEinstellungen.MitSchluessel(),
NullLogger<TmdbClient>.Instance);
private const string FilmTreffer = """
{"results":[{"id":603,"title":"Matrix","release_date":"1999-03-30",
"overview":"Neo zweifelt an der Wirklichkeit.",
"poster_path":"/abc.jpg","backdrop_path":"/def.jpg"}]}
""";
[Test]
public async Task Ohne_Schluessel_wird_nichts_gefragt()
{
var attrappe = Attrappe.MitJson(FilmTreffer);
var client = Client(attrappe, FesteEinstellungen.Ohne);
Assert.That(await client.IsConfiguredAsync(), Is.False);
Assert.That(await client.FindMovieAsync("Matrix", 1999), Is.Null);
Assert.That(attrappe.Aufrufe, Is.Empty, "Ohne Schlüssel darf keine Anfrage hinausgehen");
}
[Test]
public async Task Ein_Film_wird_gelesen()
{
var film = await Client(Attrappe.MitJson(FilmTreffer)).FindMovieAsync("Matrix", 1999);
Assert.That(film, Is.Not.Null);
Assert.That(film!.ProviderId, Is.EqualTo(603));
Assert.That(film.Title, Is.EqualTo("Matrix"));
Assert.That(film.Year, Is.EqualTo(1999));
Assert.That(film.Overview, Does.StartWith("Neo zweifelt"));
Assert.That(film.Poster!.Url, Is.EqualTo("https://image.tmdb.org/t/p/w500/abc.jpg"));
Assert.That(film.Backdrop!.Url, Is.EqualTo("https://image.tmdb.org/t/p/w1280/def.jpg"));
}
[Test]
public async Task Titel_Jahr_und_Sprache_landen_in_der_Anfrage()
{
var attrappe = Attrappe.MitJson(FilmTreffer);
await Client(attrappe, FesteEinstellungen.MitSchluessel("de-DE")).FindMovieAsync("Der Untergang", 2004);
// Die erste Anfrage ist die Suche; danach folgt der Nachschlag der
// IMDb-Kennung für die NFO-Datei.
var aufruf = attrappe.Aufrufe[0];
Assert.That(aufruf, Does.Contain("query=Der%20Untergang"));
Assert.That(aufruf, Does.Contain("year=2004"));
Assert.That(aufruf, Does.Contain("language=de-DE"));
}
[Test]
public async Task Ohne_Jahr_wird_keines_mitgeschickt()
{
var attrappe = Attrappe.MitJson(FilmTreffer);
await Client(attrappe).FindMovieAsync("Matrix", null);
Assert.That(attrappe.Aufrufe[0], Does.Not.Contain("year="));
}
[Test]
public async Task Eine_leere_Trefferliste_ergibt_nichts()
{
Assert.That(await Client(Attrappe.MitJson("""{"results":[]}""")).FindMovieAsync("Gibtesnicht", null), Is.Null);
}
/// <summary>Ein falscher Schlüssel muss auffallen.</summary>
///
/// <remarks>
/// <b>„Nichts gefunden" und „dein Schlüssel ist falsch" sehen in der
/// Mediathek gleich aus.</b> Ohne diese Unterscheidung sucht jemand
/// stundenlang nach dem Grund, warum keine Bilder erscheinen.
/// </remarks>
[TestCase(HttpStatusCode.Unauthorized)]
[TestCase(HttpStatusCode.Forbidden)]
public void Ein_zurueckgewiesener_Schluessel_wirft(HttpStatusCode code)
{
var client = Client(Attrappe.MitStatus(code));
var fehler = Assert.ThrowsAsync<MetadataCredentialsException>(() => client.FindMovieAsync("Matrix", null));
Assert.That(fehler!.Message, Does.Contain("Schlüssel"));
}
/// <summary>Alles andere ist kein Grund, den Scan abzubrechen.</summary>
[TestCase(HttpStatusCode.NotFound)]
[TestCase(HttpStatusCode.InternalServerError)]
[TestCase(HttpStatusCode.TooManyRequests)]
[TestCase(HttpStatusCode.ServiceUnavailable)]
public async Task Andere_Fehler_ergeben_still_nichts(HttpStatusCode code)
{
Assert.That(await Client(Attrappe.MitStatus(code)).FindMovieAsync("Matrix", null), Is.Null);
}
/// <summary>Ein Anmeldeportal im Hotel-WLAN antwortet mit HTML und Status 200.</summary>
///
/// <remarks>
/// Darauf mit einer Ausnahme zu reagieren hiesse, den ganzen Scan an einem
/// fremden Netz scheitern zu lassen.
/// </remarks>
[Test]
public async Task Unsinn_statt_JSON_wirft_nicht()
{
var attrappe = Attrappe.MitJson("<html><body>Bitte anmelden</body></html>");
Assert.That(await Client(attrappe).FindMovieAsync("Matrix", null), Is.Null);
}
[Test]
public async Task Kein_Netz_ergibt_nichts_statt_eines_Absturzes()
{
var attrappe = Attrappe.DieWirft(new HttpRequestException("Name or service not known"));
Assert.That(await Client(attrappe).FindMovieAsync("Matrix", null), Is.Null);
}
[Test]
public async Task Eine_Serie_wird_gelesen()
{
var json = """
{"results":[{"id":70523,"name":"Dark","first_air_date":"2017-12-01",
"overview":"Vier Familien.","poster_path":"/p.jpg","backdrop_path":null}]}
""";
var serie = await Client(Attrappe.MitJson(json)).FindSeriesAsync("Dark", null);
Assert.That(serie!.ProviderId, Is.EqualTo(70523));
Assert.That(serie.Title, Is.EqualTo("Dark"));
Assert.That(serie.Year, Is.EqualTo(2017));
Assert.That(serie.Poster!.Url, Does.EndWith("/p.jpg"));
Assert.That(serie.Backdrop, Is.Null, "Ein fehlendes Bild ist null, keine kaputte Adresse");
}
[Test]
public async Task Eine_Folge_wird_gelesen()
{
var attrappe = Attrappe.MitJson("""
{"name":"Geheimnisse","overview":"Ein Junge verschwindet.","still_path":"/s.jpg"}
""");
var folge = await Client(attrappe).FindEpisodeAsync(70523, 1, 1);
Assert.That(folge!.Title, Is.EqualTo("Geheimnisse"));
Assert.That(folge.Still!.Url, Is.EqualTo("https://image.tmdb.org/t/p/w300/s.jpg"));
Assert.That(attrappe.Aufrufe.Single(), Does.Contain("tv/70523/season/1/episode/1"));
}
/// <summary>Eine Folge, die TMDB nicht kennt, ist kein Fehler.</summary>
[Test]
public async Task Eine_unbekannte_Folge_ergibt_nichts()
{
Assert.That(await Client(Attrappe.MitStatus(HttpStatusCode.NotFound)).FindEpisodeAsync(1, 99, 99), Is.Null);
}
[Test]
public async Task Leere_Felder_werden_zu_null_statt_zu_Leerstrings()
{
var json = """{"results":[{"id":1,"title":"Ohne","release_date":"","overview":"","poster_path":""}]}""";
var film = await Client(Attrappe.MitJson(json)).FindMovieAsync("Ohne", null);
Assert.That(film!.Overview, Is.Null);
Assert.That(film.Year, Is.Null);
Assert.That(film.Poster, Is.Null);
}
}
/// <summary>Welcher Treffer gewinnt, wenn mehrere in Frage kommen.</summary>
public class TrefferwahlTests
{
private static TmdbClient Client(Attrappe attrappe) =>
new(new HttpClient(attrappe) { BaseAddress = new Uri(TmdbClient.BaseAddress) },
FesteEinstellungen.MitSchluessel(), NullLogger<TmdbClient>.Instance);
/// <summary>Am echten Dienst gemessen, nicht vermutet.</summary>
///
/// <remarks>
/// <b>Die Suche nach „Dark" lieferte „Dark Shadows" von 1966.</b> 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.
/// </remarks>
[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));
}
/// <summary>Ohne genaue Entsprechung bleibt es beim beliebtesten.</summary>
///
/// <remarks>
/// Eine eigene Ähnlichkeitsbewertung zu bauen hiesse, TMDBs Rangfolge
/// nachzubauen — mit weniger Daten.
/// </remarks>
[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));
}
}