using System.Diagnostics; using System.Net; using System.Net.Http.Json; using Flaro.Api.Endpoints; using Flaro.Core.Library; using Flaro.Core.Metadata; using Flaro.Media; using Microsoft.Extensions.DependencyInjection; namespace Flaro.Api.Tests; /// Eine falsche Zuordnung richtigstellen — und sie festschreiben. public class MatchCorrectionTests { private string _bestand = ""; private string _filmOrdner = ""; private FlaroServer? _server; private static string? Werkzeug(string name) => (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator) .Select(o => o.Length == 0 ? null : Path.Combine(o, name)) .FirstOrDefault(p => p is not null && File.Exists(p)); [SetUp] public void Anlegen() { if (Werkzeug("ffmpeg") is null) Assert.Ignore("ffmpeg ist auf diesem Rechner nicht installiert."); _bestand = Directory.CreateDirectory( Path.Combine(Path.GetTempPath(), "flaro-korrektur-" + Guid.CreateVersion7().ToString("N"))).FullName; _filmOrdner = Directory.CreateDirectory(Path.Combine(_bestand, "Dark (2017)")).FullName; var start = new ProcessStartInfo(Werkzeug("ffmpeg")!) { RedirectStandardError = true, UseShellExecute = false }; foreach (var a in new[] { "-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=duration=1:size=160x120:rate=10", "-c:v", "libx264", "-preset", "ultrafast", Path.Combine(_filmOrdner, "Dark (2017).mkv"), }) start.ArgumentList.Add(a); using var p = Process.Start(start)!; p.StandardError.ReadToEnd(); p.WaitForExit(); } [TearDown] public void Aufraeumen() { _server?.Dispose(); _server = null; try { if (_bestand.Length > 0) Directory.Delete(_bestand, recursive: true); } catch (IOException) { } } /// Ein Anbieter, der zwei verschiedene Filme kennt. private sealed class ZweiTreffer : IMetadataProvider { public static readonly MovieMetadata Falsch = new(1, "Dark Shadows", 1966, "Eine Fernsehserie von 1966.", null, null, "tt0059978"); public static readonly MovieMetadata Richtig = new(70523, "Dark", 2017, "Nach dem Verschwinden eines Kindes.", null, null, "tt5753856"); /// Ein Treffer mit anderem Jahr als im Dateinamen. public static readonly MovieMetadata AnderesJahr = new(613, "Der Untergang", 2004, "Berlin, April 1945.", null, null, "tt0363163"); public Task IsConfiguredAsync(CancellationToken ct = default) => Task.FromResult(true); // Die Suche liefert den beliebteren zuerst — genau wie TMDB. public Task FindMovieAsync(string t, int? j, CancellationToken ct = default) => Task.FromResult(Falsch); /// Womit zuletzt gesucht wurde — die Prüfungen unten lesen es. public static int? ZuletztesJahr { get; private set; } public static bool Gefragt { get; private set; } public Task> SearchMoviesAsync(string t, int? j, CancellationToken ct = default) { ZuletztesJahr = j; Gefragt = true; return Task.FromResult>([Falsch, Richtig]); } public static void Vergessen() { ZuletztesJahr = null; Gefragt = false; } public Task GetMovieAsync(int id, CancellationToken ct = default) => Task.FromResult( id == Richtig.ProviderId ? Richtig : id == Falsch.ProviderId ? Falsch : id == AnderesJahr.ProviderId ? AnderesJahr : null); public Task FindSeriesAsync(string t, int? j, CancellationToken ct = default) => Task.FromResult(null); public Task> SearchSeriesAsync(string t, int? j, CancellationToken ct = default) => Task.FromResult>([]); public Task GetSeriesAsync(int id, CancellationToken ct = default) => Task.FromResult(null); public Task FindEpisodeAsync(int s, int st, int f, CancellationToken ct = default) => Task.FromResult(null); } private async Task<(HttpClient Client, MovieResponse Film)> Eingelesen(bool beiwerkSchreiben) { _server = new FlaroServer(dienste => { dienste.AddScoped(); dienste.AddSingleton( new SidecarWriter(new SidecarOptions(beiwerkSchreiben), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); }); var admin = await _server.SetUpAndSignIn(); var client = _server.AlsAngemeldeter(admin.Token); var angelegt = await client.PostAsJsonAsync("/api/v1/libraries", new CreateLibraryRequest("Filme", "Movies", _bestand)); angelegt.EnsureSuccessStatusCode(); var bibliothek = (await angelegt.Content.ReadFromJsonAsync())!; (await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode(); for (var i = 0; i < 150; i++) { var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek.Id}/scan"); if (stand!.State == "finished") break; await Task.Delay(50); } var filme = await client.GetFromJsonAsync>("/api/v1/movies"); return (client, filme!.Items.Single()); } [Test] public async Task Der_Scan_ordnet_zuerst_falsch_zu() { var (_, film) = await Eingelesen(beiwerkSchreiben: false); Assert.That(film.Title, Is.EqualTo("Dark Shadows"), "So sieht der Fehler aus, den es zu beheben gilt"); } [Test] public async Task Die_Vorschlagsliste_enthaelt_den_richtigen() { var (client, film) = await Eingelesen(beiwerkSchreiben: false); var vorschläge = await client.GetFromJsonAsync>($"/api/v1/movies/{film.Id}/matches"); Assert.That(vorschläge, Has.Count.EqualTo(2)); Assert.That(vorschläge!.Select(v => v.Title), Does.Contain("Dark")); } /// Ein leeres Jahrfeld heisst „ich weiss es nicht", nicht „nimm das vom Dateinamen". /// /// /// Der Anlass. „Plan B (2010)" heisst bei TMDB „DivertiMento – /// Plan B" und ist von 2011. Wer das Jahr aus der Maske herausnahm, um /// ohne es zu suchen, bekam trotzdem keinen Treffer: der Server reichte /// das Jahr aus dem Dateinamen nach. Das leere Feld hatte damit gar /// keine Wirkung, und ein falsch benannter Film war von Hand nicht zu /// finden. /// [Test] public async Task Ohne_Jahr_wird_wirklich_ohne_Jahr_gesucht() { var (client, film) = await Eingelesen(beiwerkSchreiben: false); ZweiTreffer.Vergessen(); await client.GetFromJsonAsync>( $"/api/v1/movies/{film.Id}/matches?query=Plan%20B"); Assert.Multiple(() => { Assert.That(ZweiTreffer.Gefragt, Is.True, "die Suche muss überhaupt stattgefunden haben"); Assert.That(ZweiTreffer.ZuletztesJahr, Is.Null, "das Jahr aus dem Dateinamen darf nicht nachgereicht werden"); }); } /// Ein angegebenes Jahr geht unverändert weiter. [Test] public async Task Ein_angegebenes_Jahr_kommt_an() { var (client, film) = await Eingelesen(beiwerkSchreiben: false); ZweiTreffer.Vergessen(); await client.GetFromJsonAsync>( $"/api/v1/movies/{film.Id}/matches?query=Plan%20B&year=2011"); Assert.That(ZweiTreffer.ZuletztesJahr, Is.EqualTo(2011)); } [Test] public async Task Eine_Korrektur_greift_sofort() { var (client, film) = await Eingelesen(beiwerkSchreiben: false); var antwort = await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523)); antwort.EnsureSuccessStatusCode(); var stand = await antwort.Content.ReadFromJsonAsync(); Assert.That(stand!.Title, Is.EqualTo("Dark")); Assert.That(stand.TmdbId, Is.EqualTo(70523)); Assert.That(stand.ImdbId, Is.EqualTo("tt5753856")); Assert.That(stand.Locked, Is.True); var danach = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.That(danach!.Items.Single().Title, Is.EqualTo("Dark")); Assert.Multiple(() => { Assert.That(stand.Id, Is.EqualTo(film.Id), "ohne Zusammenlegen bleibt es derselbe Eintrag"); Assert.That(stand.Merged, Is.False); }); } /// Wird beim Korrigieren zusammengelegt, sagt die Antwort es auch. /// /// /// /// Sonst zeigt die Mediathek weiter auf einen gelöschten Eintrag. Der /// korrigierte Film verschwindet beim Zusammenlegen — die Kachel blieb aber /// stehen, und wer sie dann bearbeiten wollte, bekam eine 404. Angezeigt /// wurde dafür „Not Found": englisch, und für etwas, das gar kein Fehler /// ist. /// /// /// Der Ablauf ist der aus dem Betrieb, nicht der kürzeste: zwei /// Ordner gleichzeitig legt schon der Scan zusammen. Hier kommt der zweite /// dazu, nachdem der erste korrigiert wurde — dann stehen zwei /// Einträge mit verschiedenen Kennungen da, und die Korrektur des zweiten /// führt sie zusammen. /// /// [Test] public async Task Ein_Zusammenlegen_beim_Korrigieren_wird_gemeldet() { var (client, film) = await Eingelesen(beiwerkSchreiben: false); // Der erste bekommt die richtige Kennung. (await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523))) .EnsureSuccessStatusCode(); // Jetzt kommt ein zweiter Ordner mit demselben Film dazu. Zweitordner("Dunkel (2017)"); var bibliotheken = await client.GetFromJsonAsync>("/api/v1/libraries"); await Scannen(client, bibliotheken![0].Id); var filme = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.That(filme!.Items, Has.Count.EqualTo(2), "verschiedene Kennungen — der Scan legt sie nicht von selbst zusammen"); var zweiter = filme.Items.Single(m => m.Id != film.Id); var antwort = await client.PutAsJsonAsync( $"/api/v1/movies/{zweiter.Id}/match", new ApplyMatchRequest(70523)); antwort.EnsureSuccessStatusCode(); var stand = (await antwort.Content.ReadFromJsonAsync())!; var weg = await client.GetAsync($"/api/v1/movies/{zweiter.Id}"); Assert.Multiple(() => { Assert.That(stand.Merged, Is.True); Assert.That(stand.Id, Is.EqualTo(film.Id), "der überlebende Eintrag — genau das muss der Client erfahren"); Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.NotFound), "und der korrigierte ist wirklich weg"); }); } /// Legt einen weiteren Filmordner im Prüfbestand an. private void Zweitordner(string name) { var ordner = Directory.CreateDirectory(Path.Combine(_bestand, name)).FullName; var start = new ProcessStartInfo(Werkzeug("ffmpeg")!) { RedirectStandardError = true, UseShellExecute = false, }; foreach (var a in new[] { "-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=duration=1:size=160x120:rate=10", "-c:v", "libx264", "-preset", "ultrafast", Path.Combine(ordner, $"{name}.mkv"), }) { start.ArgumentList.Add(a); } using var prozess = Process.Start(start)!; prozess.StandardError.ReadToEnd(); prozess.WaitForExit(); } private static async Task Scannen(HttpClient client, Guid bibliothek) { (await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null)).EnsureSuccessStatusCode(); for (var i = 0; i < 200; i++) { var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan"); if (stand!.State == "finished") return; await Task.Delay(50); } Assert.Fail("Der Scan wurde nicht fertig."); } /* ---------- Sortiertitel ---------- */ /// Der Sortiertitel bestimmt die Reihenfolge, der Titel bleibt stehen. /// /// /// Wofür man ihn braucht: eine Reihe steht alphabetisch /// durcheinander. „…Orden des Phönix" kommt vor „…Stein der Weisen", weil /// O vor S liegt — obwohl der Stein der erste Teil ist. /// [Test] public async Task Ein_Sortiertitel_bestimmt_die_Reihenfolge() { var (client, film) = await Eingelesen(beiwerkSchreiben: false); (await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/sorttitle", new { title = "Zzz zuletzt" })).EnsureSuccessStatusCode(); var danach = (await client.GetFromJsonAsync($"/api/v1/movies/{film.Id}"))!; Assert.Multiple(() => { Assert.That(danach.SortTitle, Is.EqualTo("Zzz zuletzt")); Assert.That(danach.SortTitlePinned, Is.True); Assert.That(danach.Title, Is.EqualTo(film.Title), "der angezeigte Titel bleibt, wie er war"); }); } /// Ein Sortiertitel von Hand überlebt das Auffrischen. /// /// /// Sonst tippt man ihn nach jedem Lauf neu — genau wie beim Titel /// und bei den Bildern. Beides hängt aber an eigenen Merkern: wer die /// Reihenfolge festlegt, will den Titel trotzdem weiter automatisch /// bekommen. /// [Test] public async Task Ein_Sortiertitel_von_Hand_ueberlebt_eine_Korrektur() { var (client, film) = await Eingelesen(beiwerkSchreiben: false); (await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/sorttitle", new { title = "Harry Potter 3" })).EnsureSuccessStatusCode(); // Eine Korrektur schreibt Titel und Sortiertitel neu — den gesetzten nicht. (await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523))) .EnsureSuccessStatusCode(); var danach = (await client.GetFromJsonAsync($"/api/v1/movies/{film.Id}"))!; Assert.Multiple(() => { Assert.That(danach.Title, Is.EqualTo("Dark"), "der Titel kommt aus der Quelle"); Assert.That(danach.SortTitle, Is.EqualTo("Harry Potter 3"), "die Reihenfolge bleibt von Hand"); }); } /// Und auch eine Titeländerung lässt ihn stehen. [Test] public async Task Eine_Titelaenderung_laesst_den_Sortiertitel_stehen() { var (client, film) = await Eingelesen(beiwerkSchreiben: false); (await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/sorttitle", new { title = "Harry Potter 3" })).EnsureSuccessStatusCode(); (await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/title", new { title = "Ganz anderer Name" })).EnsureSuccessStatusCode(); var danach = (await client.GetFromJsonAsync($"/api/v1/movies/{film.Id}"))!; Assert.Multiple(() => { Assert.That(danach.Title, Is.EqualTo("Ganz anderer Name")); Assert.That(danach.SortTitle, Is.EqualTo("Harry Potter 3")); }); } /// „Wieder automatisch" bildet ihn sofort neu — nicht erst beim nächsten Lauf. /// /// /// Anders als beim Titel, und mit Absicht. Der Titel steht sichtbar /// da; ihn bis zum Auffrischen stehenzulassen ist besser als eine Lücke. /// Der Sortiertitel steht nirgends — bliebe er falsch, stünde die Liste /// solange in der falschen Reihenfolge, und niemand sähe warum. /// [Test] public async Task Wieder_automatisch_bildet_den_Sortiertitel_neu() { var (client, film) = await Eingelesen(beiwerkSchreiben: false); (await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/title", new { title = "Die Brücke" })).EnsureSuccessStatusCode(); (await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/sorttitle", new { title = "Zzz" })).EnsureSuccessStatusCode(); (await client.DeleteAsync($"/api/v1/movies/{film.Id}/sorttitle")).EnsureSuccessStatusCode(); var danach = (await client.GetFromJsonAsync($"/api/v1/movies/{film.Id}"))!; Assert.Multiple(() => { Assert.That(danach.SortTitle, Is.EqualTo("Brücke"), "der Artikel fällt wieder vorn weg"); Assert.That(danach.SortTitlePinned, Is.False); }); } [Test] public async Task Ein_leerer_Sortiertitel_wird_abgelehnt() { var (client, film) = await Eingelesen(beiwerkSchreiben: false); var antwort = await client.PutAsJsonAsync( $"/api/v1/movies/{film.Id}/sorttitle", new { title = " " }); Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); } [Test] public async Task Nur_Administratoren_duerfen_den_Sortiertitel_setzen() { var (client, film) = await Eingelesen(beiwerkSchreiben: false); (await client.PostAsJsonAsync("/api/v1/users", new CreateUserRequest("gast", "ein-langes-passwort", false))).EnsureSuccessStatusCode(); var gast = _server!.AlsAngemeldeter( (await _server.SignIn("gast", "ein-langes-passwort", "iPad", "geraet-2")).Token); var antwort = await gast.PutAsJsonAsync( $"/api/v1/movies/{film.Id}/sorttitle", new { title = "Egal" }); Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); } /// Die Korrektur überlebt einen Neuaufbau der Datenbank. /// /// /// Darum geht es bei der NFO-Datei. In der Datenbank allein wäre /// die Entscheidung mit dem nächsten Umzug oder Neuaufbau verloren. /// [Test] public async Task Eine_Korrektur_landet_in_der_NFO_Datei() { var (client, film) = await Eingelesen(beiwerkSchreiben: true); (await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523))) .EnsureSuccessStatusCode(); var nfo = Path.Combine(_filmOrdner, "movie.nfo"); Assert.That(File.Exists(nfo), Is.True, "Die NFO muss neben dem Film liegen"); var inhalt = await File.ReadAllTextAsync(nfo); var kennungen = Nfo.ReadIds(inhalt); Assert.That(kennungen.Tmdb, Is.EqualTo(70523)); Assert.That(kennungen.Imdb, Is.EqualTo("tt5753856")); Assert.That(Nfo.ReadLocked(inhalt), Is.True, "Damit kein Lauf die Entscheidung überstimmt"); Assert.That(inhalt, Does.Contain("Dark")); } [Test] public async Task Ohne_Schreiberlaubnis_bleibt_der_Ordner_unberuehrt() { var (client, film) = await Eingelesen(beiwerkSchreiben: false); (await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523))) .EnsureSuccessStatusCode(); Assert.That(Directory.GetFiles(_filmOrdner).Select(Path.GetFileName), Is.EqualTo(new[] { "Dark (2017).mkv" })); } [Test] public async Task Eine_Kennung_die_es_nicht_gibt_wird_abgelehnt() { var (client, film) = await Eingelesen(beiwerkSchreiben: false); var antwort = await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(999999)); Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); } /// Eine Korrektur muss den nächsten Scan überstehen. /// /// /// Live gefunden, und der Fehler war unangenehm. 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 — die Zuordnung überlebte nur, /// weil sie in der NFO stand. Zugeordnet wird deshalb über /// ParsedYear, das sich nie ändert. /// [Test] public async Task Eine_Korrektur_ueberlebt_den_naechsten_Scan() { var (client, film) = await Eingelesen(beiwerkSchreiben: false); (await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(613))) .EnsureSuccessStatusCode(); var bibliothek = (await client.GetFromJsonAsync>("/api/v1/libraries"))!.Single(); (await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)) .EnsureSuccessStatusCode(); for (var i = 0; i < 150; i++) { var stand = await client.GetFromJsonAsync( $"/api/v1/libraries/{bibliothek.Id}/scan"); if (stand!.State == "finished") break; await Task.Delay(50); } var danach = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.That(danach!.Total, Is.EqualTo(1), "Kein zweiter Eintrag für denselben Film"); var geblieben = danach.Items.Single(); Assert.That(geblieben.Id, Is.EqualTo(film.Id), "Derselbe Eintrag, nicht ein neuer"); Assert.That(geblieben.Title, Is.EqualTo("Der Untergang")); Assert.That(geblieben.Year, Is.EqualTo(2004), "Und nicht wieder 2017 aus dem Dateinamen"); } [Test] public async Task Nur_Administratoren_duerfen_korrigieren() { var (client, film) = await Eingelesen(beiwerkSchreiben: false); await client.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) .PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523)); Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); } }