using System.Net; using System.Net.Http.Json; using Flaro.Api.Endpoints; namespace Flaro.Api.Tests; /// Scan gegen einen echten Ordner mit echten Dateien. public class ScanTests { private string _bestand = ""; [SetUp] public void Anlegen() => _bestand = Directory.CreateDirectory( Path.Combine(Path.GetTempPath(), "flaro-medien-" + Guid.CreateVersion7().ToString("N"))).FullName; [TearDown] public void Aufraeumen() { try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { } } private void Datei(string relativ, int bytes = 2048) { var voll = Path.Combine(_bestand, relativ); Directory.CreateDirectory(Path.GetDirectoryName(voll)!); File.WriteAllBytes(voll, new byte[bytes]); } private async Task<(HttpClient Admin, Guid LibraryId)> MitBibliothek(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(); var bibliothek = (await angelegt.Content.ReadFromJsonAsync())!; return (client, bibliothek.Id); } /// Stösst an und wartet, bis der Hintergrundlauf fertig ist. private static async Task Scannen(HttpClient client, Guid bibliothek) { var angestossen = await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null); Assert.That(angestossen.StatusCode, Is.EqualTo(HttpStatusCode.Accepted)); // **Dreissig Sekunden, nicht fünf.** Die alte Grenze war für zwei, // drei Dateien bemessen. Der Test über 120 Dateien schaffte sie // allein, fiel aber durch, sobald die ganze Suite nebenher lief — // und das war kein Fehler im Scan, sondern eine zu knappe Geduld. // Teurer wird es nur, wenn wirklich etwas hängt. const int schritte = 600, abstand = 50; for (var versuch = 0; versuch < schritte; versuch++) { var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan"); if (stand!.State is "finished") return stand; if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}"); await Task.Delay(abstand); } Assert.Fail($"Der Scan wurde in {schritte * abstand / 1000} Sekunden nicht fertig."); return null!; } [Test] public async Task Filme_werden_gefunden_und_benannt() { Datei("The.Matrix.1999.1080p.BluRay.x264-GRP.mkv"); Datei("Der Untergang (2004).mkv"); Datei("The Matrix (1999).srt"); // zählt nicht Datei("@eaDir/The.Matrix.1999.mkv"); // zählt auch nicht using var server = new FlaroServer(); var (client, bibliothek) = await MitBibliothek(server, "Movies"); var stand = await Scannen(client, bibliothek); Assert.That(stand.FilesFound, Is.EqualTo(2)); Assert.That(stand.Added, Is.EqualTo(2)); var filme = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.That(filme!.Total, Is.EqualTo(2)); // Sortiert ohne Artikel: „Matrix" vor „Untergang". Assert.That(filme.Items.Select(m => m.Title), Is.EqualTo(new[] { "The Matrix", "Der Untergang" })); Assert.That(filme.Items[0].Year, Is.EqualTo(1999)); } /// Zweimal scannen muss dasselbe ergeben wie einmal. /// /// /// Sonst wächst die Mediathek bei jedem Lauf. Das ist der Fehler, /// den man erst nach Wochen bemerkt — wenn jeder Film fünfmal dasteht. /// [Test] public async Task Ein_zweiter_Lauf_aendert_nichts() { Datei("The Matrix (1999).mkv"); Datei("Inception (2010).mkv"); using var server = new FlaroServer(); var (client, bibliothek) = await MitBibliothek(server, "Movies"); await Scannen(client, bibliothek); var zweiter = await Scannen(client, bibliothek); Assert.That(zweiter.Added, Is.EqualTo(0)); Assert.That(zweiter.Removed, Is.EqualTo(0)); Assert.That((await client.GetFromJsonAsync>("/api/v1/movies"))!.Total, Is.EqualTo(2)); } /// Eine verschwundene Datei wird gemeldet, nicht stillschweigend gelöscht. /// /// /// Vorher fiel hier der Titel samt Sehfortschritt. Das ist für eine /// wirklich gelöschte Datei richtig — und falsch für jeden anderen Grund, /// aus dem eine Datei kurz nicht da ist. Beides sieht für den Scan gleich /// aus, also entscheidet jetzt der Administrator. /// [Test] public async Task Eine_verschwundene_Datei_wird_gemeldet_statt_geloescht() { Datei("The Matrix (1999).mkv"); Datei("Inception (2010).mkv"); using var server = new FlaroServer(); var (client, bibliothek) = await MitBibliothek(server, "Movies"); await Scannen(client, bibliothek); File.Delete(Path.Combine(_bestand, "Inception (2010).mkv")); var danach = await Scannen(client, bibliothek); Assert.That(danach.Missing, Is.EqualTo(1)); Assert.That((await client.GetFromJsonAsync>("/api/v1/movies"))!.Total, Is.EqualTo(2), "Der Titel bleibt stehen, bis jemand ihn entfernt."); var vermisst = await client.GetFromJsonAsync>("/api/v1/libraries/missing"); Assert.That(vermisst, Has.Count.EqualTo(1)); Assert.That(vermisst![0].Title, Is.EqualTo("Inception")); // Und erst auf Ansage ist er wirklich weg. (await client.DeleteAsync("/api/v1/libraries/missing")).EnsureSuccessStatusCode(); var filme = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.That(filme!.Items.Select(m => m.Title), Is.EqualTo(new[] { "The Matrix" })); } /// Kommt die Datei zurück, verschwindet die Markierung von selbst. [Test] public async Task Eine_zurueckgekehrte_Datei_gilt_wieder_als_da() { // Zwei Dateien, damit der Ordner nicht leer wird — sonst greift die // Notbremse aus dem Test darunter, und zwar zu Recht. Datei("Inception (2010).mkv"); Datei("The Matrix (1999).mkv"); using var server = new FlaroServer(); var (client, bibliothek) = await MitBibliothek(server, "Movies"); await Scannen(client, bibliothek); var pfad = Path.Combine(_bestand, "Inception (2010).mkv"); var inhalt = await File.ReadAllBytesAsync(pfad); File.Delete(pfad); await Scannen(client, bibliothek); Assert.That(await client.GetFromJsonAsync>("/api/v1/libraries/missing"), Has.Count.EqualTo(1)); await File.WriteAllBytesAsync(pfad, inhalt); await Scannen(client, bibliothek); Assert.That(await client.GetFromJsonAsync>("/api/v1/libraries/missing"), Is.Empty); } /// Die Notbremse: ein leerer Ordner bricht ab, statt alles zu verwerfen. /// /// /// Der teuerste denkbare Fehler. Ist das Volume beim Start des NAS /// noch nicht eingehängt, liegt der Ordner leer da — und ohne diese Prüfung /// gälte der gesamte Bestand als verschwunden. /// [Test] public async Task Ein_ploetzlich_leerer_Ordner_bricht_den_Scan_ab() { Datei("The Matrix (1999).mkv"); Datei("Inception (2010).mkv"); using var server = new FlaroServer(); var (client, bibliothek) = await MitBibliothek(server, "Movies"); await Scannen(client, bibliothek); foreach (var d in Directory.EnumerateFiles(_bestand, "*", SearchOption.AllDirectories)) File.Delete(d); var angestossen = await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null); Assert.That(angestossen.StatusCode, Is.EqualTo(HttpStatusCode.Accepted)); ScanStatus? stand = null; for (var versuch = 0; versuch < 600; versuch++) { stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan"); if (stand!.State is "failed" or "finished") break; await Task.Delay(50); } Assert.That(stand!.State, Is.EqualTo("failed")); Assert.That(stand.Error, Does.Contain("abgebrochen")); Assert.That((await client.GetFromJsonAsync>("/api/v1/movies"))!.Total, Is.EqualTo(2), "Es darf nichts verändert worden sein."); Assert.That(await client.GetFromJsonAsync>("/api/v1/libraries/missing"), Is.Empty, "Auch nicht als vermisst markiert."); } /// Das Aufräumen darf nicht wegräumen, was derselbe Lauf schrieb. /// /// /// Mehr als fünfzig Dateien, und das ist der Punkt. Seit der Scan /// alle fünfzig Dateien zwischenspeichert, liegt ein Teil des Bestands /// schon in der Datenbank, während der Lauf noch läuft. Direkt danach /// löscht er alles, was eine ältere SeenAt-Zeit trägt als dieser /// Lauf — würde die Zeit irgendwo neu genommen statt vor der Schleife /// festgehalten, risse dieser Schritt den halben Bestand mit. /// /// Die Dateien hier sind Nullbytes, an denen ffprobe scheitert. Damit /// greift das Zwischenspeichern aus der Schleife und nicht das aus der /// Untersuchung — also genau der Weg, der sonst ungeprüft bliebe. /// [Test] public async Task Ein_grosser_Bestand_ueberlebt_das_Aufraeumen() { for (var i = 1; i <= 120; i++) Datei($"Prüffilm {i:000} (2024).mkv"); using var server = new FlaroServer(); var (client, bibliothek) = await MitBibliothek(server, "Movies"); var erster = await Scannen(client, bibliothek); Assert.That(erster.Added, Is.EqualTo(120)); Assert.That(erster.Removed, Is.Zero, "Ein erster Lauf darf nichts entfernen."); Assert.That((await client.GetFromJsonAsync>("/api/v1/movies"))!.Total, Is.EqualTo(120)); // Und ein zweiter Lauf bleibt ebenso vollständig. var zweiter = await Scannen(client, bibliothek); Assert.That(zweiter.Removed, Is.Zero); Assert.That((await client.GetFromJsonAsync>("/api/v1/movies"))!.Total, Is.EqualTo(120)); } /// Schreibvarianten desselben Titels ergeben einen Eintrag. /// /// /// Der Fall, der die Mediathek doppelt aussehen liess. Der Scan /// verglich den Titel aus dem Dateinamen zeichengenau — ein Punkt /// Unterschied, und es wurden zwei Filme, die anschliessend beide /// denselben TMDB-Eintrag bekamen und ununterscheidbar nebeneinander /// standen. /// [Test] public async Task Schreibvarianten_ergeben_einen_Film() { Datei("Dr. Dolittle 2 (2001).mkv"); Datei("Dr Dolittle 2.mkv"); using var server = new FlaroServer(); var (client, bibliothek) = await MitBibliothek(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].Files, Has.Count.EqualTo(2)); Assert.That(filme.Items[0].Year, Is.EqualTo(2001), "Das Jahr der einen Datei gilt auch, wenn die andere keines trägt."); } /// Eine mobile Fassung ist kein eigener Film. [Test] public async Task Eine_optimierte_Fassung_bleibt_beim_Film() { Datei("The Matrix (1999).mkv"); Datei("The Matrix (1999) - Mobil.mp4"); using var server = new FlaroServer(); var (client, bibliothek) = await MitBibliothek(server, "Movies"); await Scannen(client, bibliothek); var filme = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.That(filme!.Total, Is.EqualTo(1), "Sonst stünde ein zweiter Film ‚The Matrix - Mobil‘ daneben."); Assert.That(filme.Items[0].Title, Is.EqualTo("The Matrix")); Assert.That(filme.Items[0].Files, Has.Count.EqualTo(2)); } /// Gleicher Name, anderes Jahr — zwei Filme, und das muss so bleiben. [Test] public async Task Zwei_Verfilmungen_bleiben_zwei_Filme() { Datei("Dune (1984).mkv"); Datei("Dune (2021).mkv"); using var server = new FlaroServer(); var (client, bibliothek) = await MitBibliothek(server, "Movies"); await Scannen(client, bibliothek); var filme = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.That(filme!.Total, Is.EqualTo(2)); } /// Eine Serie, die ihren Namen verliert, bleibt nicht als Leiche stehen. /// /// /// Der Weg, den ein vorhandener Bestand nach dem Update geht. Vorher /// bestimmte der Dateiname die Serie; eine Datei direkt in der Wurzel /// ergibt das bis heute. Wandert sie in einen Ordner, gehört sie zu diesem /// — und der alte Eintrag muss verschwinden, statt als leere Serie in der /// Mediathek zu bleiben. /// [Test] public async Task Eine_verwaiste_Serie_verschwindet_beim_naechsten_Lauf() { Datei("S01E01 Kapitel 1.mkv"); using var server = new FlaroServer(); var (client, bibliothek) = await MitBibliothek(server, "Shows"); await Scannen(client, bibliothek); var vorher = await client.GetFromJsonAsync>("/api/v1/series"); Assert.That(vorher!.Total, Is.EqualTo(1), "Ohne Ordner bleibt nur der Dateiname."); var alterName = vorher.Items[0].Title; // **Verschieben, nicht löschen und neu anlegen.** Beim Verschieben // bleibt der Zeitstempel erhalten — und genau daran erkennt der Scan, // dass es dieselbe Datei ist. Ein Test, der sie neu anlegt, prüft // etwas anderes als das, was ein Mensch tut. var alt = Path.Combine(_bestand, "S01E01 Kapitel 1.mkv"); var neuerPfad = Path.Combine(_bestand, "Nachtstreife", "S01E01 Kapitel 1.mkv"); Directory.CreateDirectory(Path.GetDirectoryName(neuerPfad)!); File.Move(alt, neuerPfad); await Scannen(client, bibliothek); var nachher = await client.GetFromJsonAsync>("/api/v1/series"); Assert.That(nachher!.Total, Is.EqualTo(1), "Die alte Serie darf nicht danebenstehen."); Assert.That(nachher.Items[0].Title, Is.EqualTo("Nachtstreife")); Assert.That(nachher.Items[0].Title, Is.Not.EqualTo(alterName)); Assert.That(nachher.Items[0].EpisodeCount, Is.EqualTo(1)); } /// 4K und 1080p sind ein Film mit zwei Dateien, nicht zwei Filme. [Test] public async Task Zwei_Fassungen_desselben_Films_bleiben_ein_Eintrag() { Datei("The Matrix (1999) - 1080p.mkv"); Datei("The Matrix (1999) - 2160p.mkv"); using var server = new FlaroServer(); var (client, bibliothek) = await MitBibliothek(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].Files, Has.Count.EqualTo(2)); } [Test] public async Task Serien_werden_in_Staffeln_und_Folgen_zerlegt() { Datei("Dark/Staffel 1/Dark - S01E01 - Geheimnisse.mkv"); Datei("Dark/Staffel 1/Dark - S01E02 - Lügen.mkv"); Datei("Dark/Staffel 2/Dark - S02E01 - Anfänge.mkv"); Datei("Notruf.Hafenkante.S01E01.German.1080p.mkv"); using var server = new FlaroServer(); var (client, bibliothek) = await MitBibliothek(server, "Shows"); await Scannen(client, bibliothek); var serien = await client.GetFromJsonAsync>("/api/v1/series"); Assert.That(serien!.Total, Is.EqualTo(2)); var dark = serien.Items.Single(s => s.Title == "Dark"); Assert.That(dark.EpisodeCount, Is.EqualTo(3)); Assert.That(dark.Seasons, Is.EqualTo(new[] { 1, 2 })); var folgen = await client.GetFromJsonAsync>($"/api/v1/series/{dark.Id}/episodes"); Assert.That(folgen!.Items.Select(f => f.Title), Is.EqualTo(new[] { "Geheimnisse", "Lügen", "Anfänge" })); var nurStaffel2 = await client.GetFromJsonAsync>( $"/api/v1/series/{dark.Id}/episodes?season=2"); Assert.That(nurStaffel2!.Items, Has.Count.EqualTo(1)); } /// Was keine Folgennummer trägt, wird nicht geraten. [Test] public async Task Dateien_ohne_Folgennummer_werden_gezaehlt_aber_nicht_zugeordnet() { Datei("Dark/Dark - S01E01.mkv"); Datei("Dark/Irgendein Mitschnitt.mkv"); using var server = new FlaroServer(); var (client, bibliothek) = await MitBibliothek(server, "Shows"); var stand = await Scannen(client, bibliothek); Assert.That(stand.FilesFound, Is.EqualTo(2)); Assert.That(stand.Unrecognised, Is.EqualTo(1)); Assert.That(stand.Added, Is.EqualTo(1)); } [Test] public async Task Die_Suche_grenzt_ein() { Datei("The Matrix (1999).mkv"); Datei("The Matrix Reloaded (2003).mkv"); Datei("Inception (2010).mkv"); using var server = new FlaroServer(); var (client, bibliothek) = await MitBibliothek(server, "Movies"); await Scannen(client, bibliothek); var treffer = await client.GetFromJsonAsync>("/api/v1/movies?search=matrix"); Assert.That(treffer!.Total, Is.EqualTo(2)); } /// Eine Anfrage darf sich die Seitengrösse nicht selbst aussuchen. [Test] public async Task Die_Seitengroesse_ist_gedeckelt() { for (var i = 1; i <= 5; i++) Datei($"Film {i} (200{i}).mkv"); using var server = new FlaroServer(); var (client, bibliothek) = await MitBibliothek(server, "Movies"); await Scannen(client, bibliothek); var seite = await client.GetFromJsonAsync>("/api/v1/movies?take=100000"); Assert.That(seite!.Total, Is.EqualTo(5), "Die Gesamtzahl bleibt richtig"); Assert.That(seite.Items, Has.Count.LessThanOrEqualTo(200)); var zweiteSeite = await client.GetFromJsonAsync>("/api/v1/movies?skip=2&take=2"); Assert.That(zweiteSeite!.Items, Has.Count.EqualTo(2)); Assert.That(zweiteSeite.Total, Is.EqualTo(5)); } [Test] public async Task Vor_dem_ersten_Lauf_steht_der_Scan_auf_untaetig() { using var server = new FlaroServer(); var (client, bibliothek) = await MitBibliothek(server, "Movies"); var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan"); Assert.That(stand!.State, Is.EqualTo("idle")); } [Test] public async Task Ein_gewoehnlicher_Benutzer_stoesst_keinen_Scan_an() { using var server = new FlaroServer(); var (admin, bibliothek) = await MitBibliothek(server, "Movies"); await admin.PostAsJsonAsync("/api/v1/users", new CreateUserRequest("gast", "auch-ein-langes-passwort", false)); var gast = await server.SignIn("gast", "auch-ein-langes-passwort", "iPad", "g-9"); var versuch = await server.AlsAngemeldeter(gast.Token) .PostAsync($"/api/v1/libraries/{bibliothek}/scan", null); Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); } [Test] public async Task Eine_Bibliothek_die_es_nicht_gibt_laesst_sich_nicht_scannen() { using var server = new FlaroServer(); var (client, _) = await MitBibliothek(server, "Movies"); var versuch = await client.PostAsync($"/api/v1/libraries/{Guid.CreateVersion7()}/scan", null); Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); } }