using System.Net; using System.Net.Http.Json; using Flaro.Api.Endpoints; using Flaro.Core.Metadata; using Microsoft.Extensions.DependencyInjection; namespace Flaro.Api.Tests; /// Ein Anbieter, der antwortet, was der Test vorgibt. public sealed class VorgegebenerAnbieter : IMetadataProvider { public bool Konfiguriert { get; set; } = true; public Exception? Wirft { get; set; } public MovieMetadata? Film { get; set; } public SeriesMetadata? Serie { get; set; } public EpisodeMetadata? Folge { get; set; } public List Gefragt { get; } = []; public Task IsConfiguredAsync(CancellationToken ct = default) => Task.FromResult(Konfiguriert); public Task FindMovieAsync(string titel, int? jahr, CancellationToken ct = default) { Gefragt.Add($"film:{titel}:{jahr}"); if (Wirft is not null) throw Wirft; return Task.FromResult(Film); } public Task FindSeriesAsync(string titel, int? jahr, CancellationToken ct = default) { Gefragt.Add($"serie:{titel}"); if (Wirft is not null) throw Wirft; return Task.FromResult(Serie); } public Task FindEpisodeAsync(int serie, int staffel, int folge, CancellationToken ct = default) { Gefragt.Add($"folge:{serie}:{staffel}:{folge}"); if (Wirft is not null) throw Wirft; return Task.FromResult(Folge); } public Task GetMovieAsync(int id, CancellationToken ct = default) { Gefragt.Add($"filmId:{id}"); if (Wirft is not null) throw Wirft; return Task.FromResult(Film); } public Task GetSeriesAsync(int id, CancellationToken ct = default) { Gefragt.Add($"serieId:{id}"); if (Wirft is not null) throw Wirft; return Task.FromResult(Serie); } public Task> SearchMoviesAsync(string t, int? j, CancellationToken ct = default) { // Mit Jahr: genau daran hing der Fehler, dass ein falscher Treffer // seine eigene Korrektur verhinderte. Gefragt.Add($"suchFilm:{t}:{j}"); return Task.FromResult>(Film is null ? [] : [Film]); } public Task> SearchSeriesAsync(string t, int? j, CancellationToken ct = default) { Gefragt.Add($"suchSerie:{t}"); return Task.FromResult>(Serie is null ? [] : [Serie]); } } /// Eine Bildablage, die nichts herunterlädt. public sealed class VorgegebeneBilder : IImageStore { public List Geholt { get; } = []; public Task StoreAsync(RemoteImage bild, CancellationToken ct = default) { Geholt.Add(bild.Url); return Task.FromResult(new string('a', 64)); } } public class MetadataTests { private string _bestand = ""; [SetUp] public void Anlegen() => _bestand = Directory.CreateDirectory( Path.Combine(Path.GetTempPath(), "flaro-meta-" + Guid.CreateVersion7().ToString("N"))).FullName; [TearDown] public void Aufraeumen() { try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { } } private void Datei(string relativ) { var voll = Path.Combine(_bestand, relativ); Directory.CreateDirectory(Path.GetDirectoryName(voll)!); File.WriteAllBytes(voll, new byte[1024]); } private FlaroServer MitAnbieter(VorgegebenerAnbieter anbieter, VorgegebeneBilder? bilder = null) => new(dienste => { dienste.AddScoped(_ => anbieter); dienste.AddScoped(_ => bilder ?? new VorgegebeneBilder()); }); private async Task<(HttpClient Admin, Guid Library)> Einrichten(FlaroServer server, string art) { var admin = await server.SetUpAndSignIn(); var client = server.AlsAngemeldeter(admin.Token); var angelegt = await client.PostAsJsonAsync("/api/v1/libraries", new CreateLibraryRequest("Prüfbestand", art, _bestand)); angelegt.EnsureSuccessStatusCode(); return (client, (await angelegt.Content.ReadFromJsonAsync())!.Id); } private static async Task Scannen(HttpClient client, Guid bibliothek) { (await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null)).EnsureSuccessStatusCode(); for (var i = 0; i < 100; i++) { var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan"); if (stand!.State == "finished") return stand; if (stand.State == "failed") Assert.Fail($"Gescheitert: {stand.Error}"); await Task.Delay(50); } Assert.Fail("Nicht fertig geworden."); return null!; } [Test] public async Task Der_Titel_aus_der_Quelle_ersetzt_den_aus_dem_Dateinamen() { Datei("The.Matrix.1999.1080p.mkv"); var anbieter = new VorgegebenerAnbieter { Film = new MovieMetadata(603, "Matrix", 1999, "Neo zweifelt.", new RemoteImage("https://bild/p.jpg"), new RemoteImage("https://bild/b.jpg")), }; var bilder = new VorgegebeneBilder(); using var server = MitAnbieter(anbieter, bilder); var (client, bibliothek) = await Einrichten(server, "Movies"); var stand = await Scannen(client, bibliothek); Assert.That(stand.Metadata!.MoviesMatched, Is.EqualTo(1)); Assert.That(anbieter.Gefragt, Does.Contain("film:The Matrix:1999")); var filme = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.That(filme!.Items[0].Title, Is.EqualTo("Matrix")); Assert.That(filme.Items[0].Overview, Is.EqualTo("Neo zweifelt.")); Assert.That(filme.Items[0].PosterImage, Is.Not.Null); Assert.That(bilder.Geholt, Is.EquivalentTo(new[] { "https://bild/p.jpg", "https://bild/b.jpg" })); } /// Zwei Namen, ein Film — das darf den Lauf nicht abbrechen. /// /// /// /// Der Fehler, den der Eindeutigkeitsindex selbst verursacht hat. /// Beim Scan heisst der eine Eintrag „Ich einfach unverbesserlich" und /// der andere „Despicable Me"; dass es derselbe Film ist, verrät erst die /// TMDB-Kennung. Wurde sie beiden zugewiesen, verletzte das die Regel — /// und EF brach die gesamte Speicherung ab, mit „An error occurred while /// saving the entity changes". Die Meldung nennt den Grund nicht und /// nimmt den ganzen Lauf mit. /// /// /// Hier antwortet der Anbieter auf jede Anfrage mit demselben Film — also /// genau die Lage, die vorher den Lauf zerriss. /// /// [Test] public async Task Zwei_Namen_fuer_denselben_Film_werden_zusammengelegt() { Datei("Despicable Me (2010).mkv"); Datei("Ich einfach unverbesserlich (2010).mkv"); var anbieter = new VorgegebenerAnbieter { Film = new MovieMetadata(20352, "Ich einfach unverbesserlich", 2010, "Gru hat einen Plan.", null, null), }; using var server = MitAnbieter(anbieter); var (client, bibliothek) = await Einrichten(server, "Movies"); var stand = await Scannen(client, bibliothek); Assert.That(stand.State, Is.EqualTo("finished"), "Der Lauf darf nicht abbrechen."); Assert.That(stand.Metadata!.Error, Is.Null); var filme = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.That(filme!.Total, Is.EqualTo(1), "Ein Film, nicht zwei."); Assert.That(filme.Items[0].Title, Is.EqualTo("Ich einfach unverbesserlich")); Assert.That(filme.Items[0].Files, Has.Count.EqualTo(2), "Beide Dateien hängen am überlebenden Eintrag — keine darf verlorengehen."); } /// Dasselbe für Serien. [Test] public async Task Zwei_Namen_fuer_dieselbe_Serie_werden_zusammengelegt() { Datei("Dark/Staffel 1/Dark - S01E01.mkv"); Datei("Dark (2017)/Staffel 1/Dark (2017) - S01E02.mkv"); var anbieter = new VorgegebenerAnbieter { Serie = new SeriesMetadata(70523, "Dark", 2017, "Eine Höhle.", null, null), }; using var server = MitAnbieter(anbieter); var (client, bibliothek) = await Einrichten(server, "Shows"); var stand = await Scannen(client, bibliothek); Assert.That(stand.State, Is.EqualTo("finished")); var serien = await client.GetFromJsonAsync>("/api/v1/series"); Assert.That(serien!.Total, Is.EqualTo(1)); Assert.That(serien.Items[0].EpisodeCount, Is.EqualTo(2), "Beide Folgen hängen an der überlebenden Serie."); } /// Was die Quelle nicht kennt, bleibt trotzdem in der Mediathek. /// „Metadaten auffrischen" muss den Titel wirklich fortschreiben. /// /// /// Der teuerste Fehler der ganzen Sprachgeschichte. Die /// Dublettenprüfung schlägt jeden Titel nach, der schon eine Kennung hat — /// beim Auffrischen also auch den gerade bearbeiteten. Ohne den Vergleich /// auf Gleichheit fand sich jeder zugeordnete Film als „Dublette seiner /// selbst" und wurde übersprungen: Titel, Beschreibung und Sprache blieben /// für immer so, wie sie beim ersten Lauf hereinkamen. /// /// In der Oberfläche sah das aus, als wirke die Spracheinstellung nicht — /// und genau so wurde es gemeldet: „eine Sprachänderung und anschliessendes /// Neuholen bringt gar nichts". /// [Test] public async Task Auffrischen_schreibt_einen_zugeordneten_Titel_fort() { Datei("101 Dalmatians (1996).mkv"); var anbieter = new VorgegebenerAnbieter { Film = new MovieMetadata(8741, "101 Dalmatians", 1996, "Englisch.", null, null) { Language = "en-US", }, }; using var server = MitAnbieter(anbieter); var (client, bibliothek) = await Einrichten(server, "Movies"); await Scannen(client, bibliothek); var vorher = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.That(vorher!.Items[0].Title, Is.EqualTo("101 Dalmatians")); // Dieselbe Kennung, andere Sprache — genau der Fall nach einer Umstellung. anbieter.Film = new MovieMetadata(8741, "101 Dalmatiner", 1996, "Deutsch.", null, null) { Language = "de-DE", }; (await client.PostAsync($"/api/v1/libraries/{bibliothek}/refresh", null)) .EnsureSuccessStatusCode(); for (var i = 0; i < 100; i++) { var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan"); if (stand!.State == "finished") break; await Task.Delay(50); } var nachher = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.That(nachher!.Total, Is.EqualTo(1), "Es bleibt ein Film, keine Dublette."); Assert.That(nachher.Items[0].Title, Is.EqualTo("101 Dalmatiner")); Assert.That(nachher.Items[0].Overview, Is.EqualTo("Deutsch.")); } /// Was von Hand benannt wurde, überlebt das Auffrischen. [Test] public async Task Ein_festgeschriebener_Titel_bleibt_beim_Auffrischen_stehen() { Datei("Irgendwas (2001).mkv"); var anbieter = new VorgegebenerAnbieter { Film = new MovieMetadata(42, "Automatisch", 2001, null, null, null) { Language = "de-DE" }, }; using var server = MitAnbieter(anbieter); var (client, bibliothek) = await Einrichten(server, "Movies"); await Scannen(client, bibliothek); var filme = await client.GetFromJsonAsync>("/api/v1/movies"); var kennung = filme!.Items[0].Id; (await client.PutAsJsonAsync($"/api/v1/movies/{kennung}/title", new { title = "Von Hand" })) .EnsureSuccessStatusCode(); anbieter.Film = new MovieMetadata(42, "Automatisch, neu", 2001, null, null, null) { Language = "de-DE", }; (await client.PostAsync($"/api/v1/libraries/{bibliothek}/refresh", null)) .EnsureSuccessStatusCode(); for (var i = 0; i < 100; i++) { var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan"); if (stand!.State == "finished") break; await Task.Delay(50); } var nachher = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.That(nachher!.Items[0].Title, Is.EqualTo("Von Hand")); } /// Auffrischen nimmt eine Auswahl entgegen — und geht auch ohne. /// /// /// Gemessen, nachdem es einmal brach. Als die Auswahl als /// RefreshRequest? im Kopf der Methode stand, lehnte der Server /// jede Anfrage ohne Rumpf mit 404 ab — also den Knopf „Metadaten neu /// holen" für die ganze Mediathek, den es länger gibt als die Auswahl. /// Beide Formen gehören deshalb in eine Prüfung. /// [Test] public async Task Auffrischen_nimmt_eine_Auswahl_entgegen_und_geht_auch_ohne() { Datei("101 Dalmatians (1996)/101 Dalmatians (1996).mkv"); var anbieter = new VorgegebenerAnbieter { Film = new MovieMetadata(8741, "101 Dalmatiner", 1996, "Deutsch.", null, null) { Language = "de-DE", }, }; using var server = MitAnbieter(anbieter); var (client, bibliothek) = await Einrichten(server, "Movies"); await Scannen(client, bibliothek); var filme = await client.GetFromJsonAsync>("/api/v1/movies"); var kennung = filme!.Items[0].Id; // Ohne Rumpf: der alte Weg, und er muss weiter gehen. var ohne = await client.PostAsync($"/api/v1/libraries/{bibliothek}/refresh", null); Assert.That(ohne.StatusCode, Is.EqualTo(HttpStatusCode.Accepted), "ein fehlender Rumpf heisst alles, nicht nicht-gefunden"); await Fertig(client, bibliothek); // Mit Auswahl: nur dieser eine Titel. var mit = await client.PostAsJsonAsync( $"/api/v1/libraries/{bibliothek}/refresh", new RefreshRequest([kennung])); Assert.That(mit.StatusCode, Is.EqualTo(HttpStatusCode.Accepted)); var stand = await Fertig(client, bibliothek); Assert.Multiple(() => { Assert.That(stand.Metadata?.MoviesMatched, Is.EqualTo(1)); Assert.That(stand.FilesFound, Is.Null, "bei einer Auswahl wird nicht noch einmal das Dateisystem abgelaufen"); }); } private static async Task Fertig(HttpClient client, Guid bibliothek) { for (var i = 0; i < 200; i++) { var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan"); if (stand!.State == "finished") return stand; if (stand.State == "failed") Assert.Fail($"Gescheitert: {stand.Error}"); await Task.Delay(50); } Assert.Fail("Nicht fertig geworden."); return null!; } /* ---------- Zwei Teile, ein Treffer ---------- */ /// Zwei Filme verschiedener Jahre werden nicht zusammengelegt. /// /// /// /// Aus dem echten Bestand, und es kostete drei Filme. „Harry Potter /// und die Heiligtümer des Todes Teil 1" und „Teil 2" landen bei TMDB auf /// demselben Treffer, sobald ohne Jahr gesucht werden muss — gemessen: /// die Suche liefert für beide Titel Teil 2 zuerst. Weil zwei /// Einträge dieselbe Kennung nicht teilen dürfen, wurde der zweite als /// „zweite Fassung" des ersten geschluckt. In der Übersicht standen dann /// 448 Filme und 13 zweite Fassungen, in Plex 451 Filme — und nirgends /// stand, welche drei fehlen. /// /// /// Das Jahr aus dem Dateinamen ändert sich nie. Widersprechen sich zwei, /// sind es verschiedene Filme, und dann bleibt der zweite lieber ohne /// Zuordnung — sichtbar in der Liste der nicht zugeordneten Titel — als /// lautlos verschwunden. /// /// [Test] public async Task Zwei_Teile_mit_verschiedenen_Jahren_bleiben_zwei_Filme() { Datei("Heiligtuemer des Todes Teil 1 (2010)/Heiligtuemer des Todes Teil 1 (2010).mkv"); Datei("Heiligtuemer des Todes Teil 2 (2011)/Heiligtuemer des Todes Teil 2 (2011).mkv"); // Die Quelle liefert für jede Suche denselben Film — genau wie TMDB // es ohne Jahr tut. var anbieter = new VorgegebenerAnbieter { Film = new MovieMetadata(12445, "Heiligtümer des Todes – Teil 2", 2011, null, null, null), }; using var server = MitAnbieter(anbieter); var (client, bibliothek) = await Einrichten(server, "Movies"); await Scannen(client, bibliothek); var filme = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.That(filme!.Items, Has.Count.EqualTo(2), "zwei Dateien, zwei Jahre — also zwei Filme, nicht einer mit zwei Fassungen"); var ohne = await client.GetFromJsonAsync>( $"/api/v1/libraries/{bibliothek}/unmatched"); Assert.That(ohne, Has.Count.EqualTo(1), "der zweite bleibt ohne Zuordnung — und steht dafür in der Liste"); } /// Derselbe Film in zwei Ordnern wird weiterhin zusammengelegt. /// /// /// Die Gegenprobe, und sie ist wichtiger als sie aussieht. „Despicable /// Me" und „Ich einfach unverbesserlich" sind wirklich derselbe Film — /// gleiches Jahr, zwei Ordner. Diesen Fall mit zu verbieten hiesse, einen /// Fehler durch einen anderen zu ersetzen. /// [Test] public async Task Derselbe_Film_in_zwei_Ordnern_wird_weiter_zusammengelegt() { Datei("Despicable Me (2010)/Despicable Me (2010).mkv"); Datei("Ich einfach unverbesserlich (2010)/Ich einfach unverbesserlich (2010).mkv"); var anbieter = new VorgegebenerAnbieter { Film = new MovieMetadata(20352, "Ich einfach unverbesserlich", 2010, null, null, null), }; using var server = MitAnbieter(anbieter); var (client, bibliothek) = await Einrichten(server, "Movies"); await Scannen(client, bibliothek); var filme = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.Multiple(() => { Assert.That(filme!.Items, Has.Count.EqualTo(1), "ein Film"); Assert.That(filme.Items[0].Files, Has.Count.EqualTo(2), "mit zwei Dateien"); }); } /// Ohne Jahr im Namen wird weiter zusammengelegt. /// /// /// Eine Lücke ist keine Aussage. Wer seine Dateien ohne Jahr /// benennt, bekäme sonst nie eine Dublette erkannt — und zwei Ordner /// desselben Films blieben für immer zwei Einträge. /// [Test] public async Task Ohne_Jahr_im_Namen_wird_weiter_zusammengelegt() { Datei("Despicable Me/Despicable Me.mkv"); Datei("Ich einfach unverbesserlich/Ich einfach unverbesserlich.mkv"); var anbieter = new VorgegebenerAnbieter { Film = new MovieMetadata(20352, "Ich einfach unverbesserlich", 2010, null, null, null), }; using var server = MitAnbieter(anbieter); var (client, bibliothek) = await Einrichten(server, "Movies"); await Scannen(client, bibliothek); var filme = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.That(filme!.Items, Has.Count.EqualTo(1)); } /// Titel ohne Zuordnung stehen in einer Liste — mit Dateinamen. [Test] public async Task Nicht_zugeordnete_Filme_stehen_in_der_Liste() { Datei("Ein Mitschnitt ohne Titel/Ein Mitschnitt ohne Titel.mkv"); // Die Quelle findet nichts. var anbieter = new VorgegebenerAnbieter { Film = null }; using var server = MitAnbieter(anbieter); var (client, bibliothek) = await Einrichten(server, "Movies"); await Scannen(client, bibliothek); var ohne = await client.GetFromJsonAsync>( $"/api/v1/libraries/{bibliothek}/unmatched"); Assert.That(ohne, Has.Count.EqualTo(1)); Assert.Multiple(() => { Assert.That(ohne![0].Art, Is.EqualTo("movie")); Assert.That(ohne[0].Dateien, Is.EqualTo(1)); Assert.That(ohne[0].Beispiel, Does.EndWith(".mkv")); Assert.That(ohne[0].Beispiel, Does.Not.Contain("/"), "nur der Dateiname, nicht der Pfad"); }); } /// Eine Datei lässt sich aus ihrem Titel herauslösen. /// /// /// Weil die Riegel nur Neues verhindern. Was schon zusammengelegt /// ist, bleibt es — „Hanni und Nanni" und „Hanni und Nanni 3" hängen dann /// an einem Eintrag, und einer der beiden Filme ist nicht mehr zu sehen. /// Der neue Eintrag bekommt den Titel aus dem Dateinamen und keine /// Kennung; eine zu raten hiesse, denselben Fehler zu wiederholen. /// [Test] public async Task Eine_Datei_laesst_sich_aus_ihrem_Titel_herausloesen() { Datei("Hanni und Nanni/Hanni und Nanni.mp4"); Datei("Hanni und Nanni/Hanni und Nanni 3.mp4"); // Die Quelle liefert für beides denselben Film — der Fall aus dem Betrieb. var anbieter = new VorgegebenerAnbieter { Film = new MovieMetadata(225313, "Hanni & Nanni 3", 2013, null, null, null), }; using var server = MitAnbieter(anbieter); var (client, bibliothek) = await Einrichten(server, "Movies"); await Scannen(client, bibliothek); var mehrfach = await client.GetFromJsonAsync( $"/api/v1/libraries/{bibliothek}/versions"); Assert.That(mehrfach!.Gesamt, Is.EqualTo(1), "zwei Dateien an einem Titel"); var dateien = mehrfach.Titel[0].Dateien; Assert.That(dateien, Has.Count.EqualTo(2)); var raus = dateien.First(f => f.Name.Contains(" 3")); var antwort = await client.PostAsync($"/api/v1/files/{raus.Id}/split", null); antwort.EnsureSuccessStatusCode(); var neu = (await antwort.Content.ReadFromJsonAsync())!; var filme = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.Multiple(() => { Assert.That(filme!.Items, Has.Count.EqualTo(2), "jetzt zwei Filme"); Assert.That(neu.Title, Is.EqualTo("Hanni und Nanni 3"), "der Titel kommt aus dem Dateinamen"); Assert.That(neu.TmdbId, Is.Null, "und ohne Kennung — geraten wird nicht"); Assert.That(filme.Items.Select(m => m.Files.Count), Is.All.EqualTo(1)); }); } /// Eine einzelne Datei lässt sich nicht herauslösen. [Test] public async Task Eine_einzelne_Datei_laesst_sich_nicht_herausloesen() { Datei("Matrix (1999)/Matrix (1999).mkv"); var anbieter = new VorgegebenerAnbieter { Film = new MovieMetadata(603, "Matrix", 1999, null, null, null), }; using var server = MitAnbieter(anbieter); var (client, bibliothek) = await Einrichten(server, "Movies"); await Scannen(client, bibliothek); var filme = await client.GetFromJsonAsync>("/api/v1/movies"); var datei = filme!.Items[0].Files.Single(); var antwort = await client.PostAsync($"/api/v1/files/{datei.Id}/split", null); Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Conflict)); } /// Eine falsche Zuordnung darf ihre eigene Korrektur nicht verhindern. /// /// /// Aus dem echten Bestand. „Doctor Strange" war fälschlich auf ein /// Making-of von 2022 gelaufen. Beim Korrigieren hängte die Suche /// year=2022 an — aus genau der Zuordnung, die gerade weg sollte — /// und lieferte nur noch Titel von 2022. Der richtige Film von 2016 konnte /// gar nicht erscheinen. Gesucht wird deshalb mit dem Jahr aus dem /// Dateinamen, nie mit dem aus dem Treffer. /// [Test] public async Task Die_Vorschlagssuche_nimmt_das_Jahr_aus_dem_Dateinamen() { Datei("Doctor Strange (2016).mkv"); var anbieter = new VorgegebenerAnbieter { // Der falsche Treffer: anderes Jahr als im Dateinamen. Film = new MovieMetadata(980017, "Das Making of", 2022, null, null, null) { Language = "de-DE", }, }; using var server = MitAnbieter(anbieter); var (client, bibliothek) = await Einrichten(server, "Movies"); await Scannen(client, bibliothek); var filme = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.That(filme!.Items[0].Year, Is.EqualTo(2022), "Der falsche Treffer sitzt."); anbieter.Gefragt.Clear(); await client.GetFromJsonAsync>( $"/api/v1/movies/{filme.Items[0].Id}/matches"); Assert.That(anbieter.Gefragt, Does.Contain("suchFilm:Doctor Strange:2016"), "Gesucht wird mit 2016 aus dem Dateinamen, nicht mit 2022 aus dem Treffer."); } /// Zwei Dateien auf denselben Film zu legen ist kein Fehler. /// /// /// Aus dem echten Bestand. Im Zuordnungsdialog stand plötzlich „An /// error occurred while processing your request." — englisch, ohne Grund. /// Der gewählte Film war in derselben Mediathek schon vergeben, der /// Eindeutigkeitsindex schlug zu, und die Ausnahme ging ungefiltert nach /// oben. /// /// Es ist aber gar kein Fehler: zwei Dateien, die denselben Film meinen, /// gehören zusammengelegt — genau das tut der Scan am Ende jedes Laufs. /// [Test] public async Task Zwei_Dateien_auf_denselben_Film_werden_zusammengelegt() { Datei("Breaking Dawn Teil 1 (2011).mkv"); Datei("Breaking Dawn (2011).mkv"); // Beim Scan findet keiner etwas — so bleiben es zwei Einträge. var anbieter = new VorgegebenerAnbieter { Film = null }; using var server = MitAnbieter(anbieter); var (client, bibliothek) = await Einrichten(server, "Movies"); await Scannen(client, bibliothek); var filme = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.That(filme!.Total, Is.EqualTo(2), "Zwei Dateien, zwei Einträge ohne Zuordnung."); anbieter.Film = new MovieMetadata(50619, "Breaking Dawn – Teil 1", 2011, null, null, null) { Language = "de-DE", }; var erste = await client.PutAsJsonAsync( $"/api/v1/movies/{filme.Items[0].Id}/match", new ApplyMatchRequest(50619)); Assert.That(erste.StatusCode, Is.EqualTo(HttpStatusCode.OK)); var zweite = await client.PutAsJsonAsync( $"/api/v1/movies/{filme.Items[1].Id}/match", new ApplyMatchRequest(50619)); Assert.That(zweite.StatusCode, Is.EqualTo(HttpStatusCode.OK), "Die zweite Zuordnung darf nicht scheitern."); var danach = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.That(danach!.Total, Is.EqualTo(1), "Ein Film."); Assert.That(danach.Items[0].Files, Has.Count.EqualTo(2), "Mit beiden Dateien."); } [Test] public async Task Ein_Film_ohne_Treffer_behaelt_seinen_Dateinamen() { Datei("Urlaub Kreta 2019.mkv"); using var server = MitAnbieter(new VorgegebenerAnbieter { Film = null }); var (client, bibliothek) = await Einrichten(server, "Movies"); await Scannen(client, bibliothek); var filme = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.That(filme!.Total, Is.EqualTo(1)); Assert.That(filme.Items[0].Title, Is.EqualTo("Urlaub Kreta")); } /// Ohne Schlüssel läuft der Scan trotzdem durch. /// /// /// Das ist der Normalfall beim allerersten Start: Bibliothek angelegt, /// Schlüssel noch nicht. Die Mediathek muss dann aus Dateinamen bestehen /// und nicht leer sein. /// [Test] public async Task Ohne_Schluessel_bleibt_es_bei_den_Dateinamen() { Datei("The Matrix (1999).mkv"); var anbieter = new VorgegebenerAnbieter { Konfiguriert = false }; using var server = MitAnbieter(anbieter); var (client, bibliothek) = await Einrichten(server, "Movies"); var stand = await Scannen(client, bibliothek); Assert.That(stand.Metadata!.Skipped, Is.True); Assert.That(anbieter.Gefragt, Is.Empty); Assert.That((await client.GetFromJsonAsync>("/api/v1/movies"))!.Total, Is.EqualTo(1)); } /// Ein falscher Schlüssel steht im Scanbericht, nicht nur im Protokoll. [Test] public async Task Ein_zurueckgewiesener_Schluessel_erscheint_im_Bericht() { Datei("The Matrix (1999).mkv"); var anbieter = new VorgegebenerAnbieter { Wirft = new MetadataCredentialsException("TMDB weist den hinterlegten Schlüssel zurück."), }; using var server = MitAnbieter(anbieter); var (client, bibliothek) = await Einrichten(server, "Movies"); var stand = await Scannen(client, bibliothek); Assert.That(stand.Metadata!.Error, Does.Contain("Schlüssel")); // Die Datei ist trotzdem da — das Nachschlagen scheiterte, nicht der Scan. Assert.That((await client.GetFromJsonAsync>("/api/v1/movies"))!.Total, Is.EqualTo(1)); } [Test] public async Task Ein_zweiter_Lauf_fragt_nicht_noch_einmal() { Datei("The Matrix (1999).mkv"); var anbieter = new VorgegebenerAnbieter { Film = new MovieMetadata(603, "Matrix", 1999, "Neo.", null, null), }; using var server = MitAnbieter(anbieter); var (client, bibliothek) = await Einrichten(server, "Movies"); await Scannen(client, bibliothek); var anzahlNachErstem = anbieter.Gefragt.Count; await Scannen(client, bibliothek); Assert.That(anbieter.Gefragt, Has.Count.EqualTo(anzahlNachErstem), "Ein zugeordneter Titel wird nicht erneut nachgeschlagen"); } [Test] public async Task Serien_und_Folgen_werden_nachgeschlagen() { Datei("Dark/Dark - S01E01.mkv"); var anbieter = new VorgegebenerAnbieter { Serie = new SeriesMetadata(70523, "Dark", 2017, "Vier Familien.", null, null), Folge = new EpisodeMetadata("Geheimnisse", "Ein Junge verschwindet.", null), }; using var server = MitAnbieter(anbieter); var (client, bibliothek) = await Einrichten(server, "Shows"); var stand = await Scannen(client, bibliothek); Assert.That(stand.Metadata!.SeriesMatched, Is.EqualTo(1)); Assert.That(stand.Metadata.EpisodesMatched, Is.EqualTo(1)); Assert.That(anbieter.Gefragt, Does.Contain("folge:70523:1:1")); var serien = await client.GetFromJsonAsync>("/api/v1/series"); Assert.That(serien!.Items[0].Overview, Is.EqualTo("Vier Familien.")); var folgen = await client.GetFromJsonAsync>( $"/api/v1/series/{serien.Items[0].Id}/episodes"); Assert.That(folgen!.Items[0].Title, Is.EqualTo("Geheimnisse")); Assert.That(folgen.Items[0].Overview, Is.EqualTo("Ein Junge verschwindet.")); } /// Folgen einer Serie ohne Zuordnung werden gar nicht erst gefragt. [Test] public async Task Ohne_zugeordnete_Serie_werden_keine_Folgen_gefragt() { Datei("Unbekannt/Unbekannt - S01E01.mkv"); var anbieter = new VorgegebenerAnbieter { Serie = null }; using var server = MitAnbieter(anbieter); var (client, bibliothek) = await Einrichten(server, "Shows"); await Scannen(client, bibliothek); Assert.That(anbieter.Gefragt.Where(g => g.StartsWith("folge:")), Is.Empty); } } public class MetadataSettingsTests { /// Der Schlüssel darf nie zurückkommen. /// /// /// Er gäbe jedem Angemeldeten das TMDB-Kontingent des Betreibers in die /// Hand. Zum Anzeigen genügt die Auskunft, dass einer da ist. /// [Test] public async Task Der_Schluessel_kommt_nicht_zurueck() { using var server = new FlaroServer(); var admin = await server.SetUpAndSignIn(); var client = server.AlsAngemeldeter(admin.Token); await client.PutAsJsonAsync("/api/v1/settings/metadata", new MetadataSettingsRequest("geheimer-schluessel", "de-DE")); var antwort = await client.GetAsync("/api/v1/settings/metadata"); var roh = await antwort.Content.ReadAsStringAsync(); Assert.That(roh, Does.Not.Contain("geheimer-schluessel")); var stand = await antwort.Content.ReadFromJsonAsync(); Assert.That(stand!.HasApiKey, Is.True); 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_eigenen_Schluessel_greift_der_mitgelieferte() { using var server = new FlaroServer(); var admin = await server.SetUpAndSignIn(); var stand = await server.AlsAngemeldeter(admin.Token) .GetFromJsonAsync("/api/v1/settings/metadata"); var mitgeliefert = Flaro.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 FlaroServer(); 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 FlaroServer(); 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")] [TestCase("de-de")] public async Task Eine_unsinnige_Sprachkennung_wird_abgelehnt(string sprache) { using var server = new FlaroServer(); var admin = await server.SetUpAndSignIn(); var antwort = await server.AlsAngemeldeter(admin.Token) .PutAsJsonAsync("/api/v1/settings/metadata", new MetadataSettingsRequest(null, sprache)); Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); } [Test] public async Task Ein_gewoehnlicher_Benutzer_sieht_die_Einstellungen_nicht() { using var server = new FlaroServer(); var admin = await server.SetUpAndSignIn(); await server.AlsAngemeldeter(admin.Token).PostAsJsonAsync("/api/v1/users", new CreateUserRequest("gast", "auch-ein-langes-passwort", false)); var gast = await server.SignIn("gast", "auch-ein-langes-passwort", "iPad", "g-1"); var antwort = await server.AlsAngemeldeter(gast.Token).GetAsync("/api/v1/settings/metadata"); Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); } } public class ImageEndpointTests { [Test] public async Task Bilder_gibt_es_nur_fuer_Angemeldete() { using var server = new FlaroServer(); await server.SetUpAndSignIn(); // **Ohne Gedächtnis, weil der Client nach der Anmeldung ein Cookie // hält.** Sonst prüfte dieser Test nicht den Unangemeldeten, sondern // den Angemeldeten — und bestünde nie wieder. var antwort = await server.OhneGedaechtnis().GetAsync($"/api/v1/images/{new string('a', 64)}"); Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); } /// Der Weg, der sonst die Datenbank herausgäbe. [TestCase("..%2F..%2Fconfig%2Fflaro.db")] [TestCase("nichtsechzigvierzeichen")] [TestCase("ZZZZ567890123456789012345678901234567890123456789012345678901234")] public async Task Ein_Schluessel_der_keiner_ist_fuehrt_ins_Leere(string versuch) { using var server = new FlaroServer(); var admin = await server.SetUpAndSignIn(); var antwort = await server.AlsAngemeldeter(admin.Token).GetAsync($"/api/v1/images/{versuch}"); Assert.That(antwort.StatusCode, Is.AnyOf(HttpStatusCode.NotFound, HttpStatusCode.BadRequest)); } }