using System.Diagnostics; using System.Net; using System.Net.Http.Json; using Flaro.Api.Endpoints; namespace Flaro.Api.Tests; /// Wiedergabe, die sofort anfängt — von der Anfrage bis zum Segment. /// /// /// Mit echtem ffmpeg, wie bei den Lieferungsprüfungen. Ob eine Playlist /// entsteht und ob darin Segmente stehen, lässt sich nicht behaupten; es muss /// jemand nachsehen. /// public class StreamTests { private string _bestand = ""; 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-strom-" + Guid.CreateVersion7().ToString("N"))).FullName; } [TearDown] public void Aufraeumen() { _server?.Dispose(); _server = null; try { if (_bestand.Length > 0) Directory.Delete(_bestand, recursive: true); } catch (IOException) { } } /// Was ein gewöhnlicher Browser kann — kein HEVC, kein EAC3. private static StreamRequest Browser(int? hoehe = null, int? ton = null, double? ab = null) => new( Containers: ["mp4"], VideoCodecs: ["h264", "avc1", "vp9"], AudioCodecs: ["aac", "mp3", "opus"], Height: hoehe, AudioStream: ton, StartSeconds: ab); /// Eine MKV mit zwei Tonspuren, die zweite in einem Codec für Apple. private string Film(string name, int hoehe = 240, int sekunden = 4) { var ziel = Path.Combine(_bestand, $"{name}.mkv"); var start = new ProcessStartInfo(Werkzeug("ffmpeg")!) { RedirectStandardError = true, UseShellExecute = false, }; foreach (var a in new[] { "-y", "-loglevel", "error", "-f", "lavfi", "-i", $"testsrc=duration={sekunden}:size={hoehe * 4 / 3}x{hoehe}:rate=10", "-f", "lavfi", "-i", $"sine=frequency=440:duration={sekunden}", "-f", "lavfi", "-i", $"sine=frequency=880:duration={sekunden}", "-map", "0:v", "-map", "1:a", "-map", "2:a", "-metadata:s:a:0", "language=deu", "-metadata:s:a:0", "title=Deutsch", "-metadata:s:a:1", "language=eng", "-metadata:s:a:1", "title=English", "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-c:a:0", "aac", "-c:a:1", "eac3", ziel, }) { start.ArgumentList.Add(a); } using var prozess = Process.Start(start)!; var fehler = prozess.StandardError.ReadToEnd(); prozess.WaitForExit(); if (prozess.ExitCode != 0) throw new InvalidOperationException(fehler); return ziel; } private async Task<(HttpClient Client, MediaFileResponse Datei)> Eingelesen() { var server = _server = new FlaroServer(); var admin = await server.SetUpAndSignIn(); var client = server.AlsAngemeldeter(admin.Token); var angelegt = await client.PostAsJsonAsync("/api/v1/libraries", new CreateLibraryRequest("Prüfbestand", "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 < 200; 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"); Assert.That(filme!.Items, Is.Not.Empty, "Der Scan hat nichts gefunden"); return (client, filme.Items[0].Files.Single()); } private static async Task Strom( HttpClient client, Guid datei, StreamRequest anfrage) { var antwort = await client.PostAsJsonAsync($"/api/v1/files/{datei}/stream", anfrage); antwort.EnsureSuccessStatusCode(); return (await antwort.Content.ReadFromJsonAsync())!; } // ------------------------------------------------------------------ /// Eine MKV wird für den Browser umgepackt, nicht neu gerechnet. [Test] public async Task Eine_mkv_wird_fuer_den_Browser_umgepackt() { Film("Umpacken (2020)"); var (client, datei) = await Eingelesen(); var strom = await Strom(client, datei.Id, Browser()); Assert.Multiple(() => { Assert.That(strom.Method, Is.EqualTo("remux"), "H.264 kennt jeder Browser — nur die Schachtel nicht"); Assert.That(strom.Height, Is.Null, "ohne Wunsch bleibt die Auflösung, wie sie ist"); Assert.That(strom.AudioStreams, Has.Count.EqualTo(2)); }); } /// Die Playlist kommt, sobald das erste Segment da ist. /// /// /// Und nicht als 204. Erst stand hier eine leere Antwort, solange /// noch nichts fertig war — hls.js hielt das für einen Fehler und gab auf. /// Gewartet wird jetzt serverseitig. /// [Test] public async Task Die_Playlist_enthaelt_Segmente() { Film("Playlist (2020)"); var (client, datei) = await Eingelesen(); var strom = await Strom(client, datei.Id, Browser()); var antwort = await client.GetAsync(strom.Url); Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK)); var text = await antwort.Content.ReadAsStringAsync(); Assert.Multiple(() => { Assert.That(text, Does.StartWith("#EXTM3U")); Assert.That(text, Does.Contain(".m4s"), "ohne Segment ist die Liste wertlos"); Assert.That(text, Does.Contain("#EXT-X-MAP:URI=\"init.mp4\"")); }); // Das Anfangsstück und das erste Segment müssen auch abholbar sein. var anfang = await client.GetAsync($"/api/v1/stream/{strom.SessionId}/init.mp4"); var erstes = await client.GetAsync($"/api/v1/stream/{strom.SessionId}/seg00000.m4s"); Assert.Multiple(() => { Assert.That(anfang.StatusCode, Is.EqualTo(HttpStatusCode.OK)); Assert.That(erstes.StatusCode, Is.EqualTo(HttpStatusCode.OK)); }); } /// Die Liste darf nicht zwischengespeichert werden, die Segmente schon. /// /// /// Aus einem Fehler gelernt. Die Wiedergabe blieb nach zwanzig /// Sekunden stehen, obwohl auf der Platte längst der ganze Rest lag: /// Results.File setzte Last-Modified, der Browser legte die /// Liste mit zwei Einträgen ab, und hls.js bekam bei jedem Nachfragen /// dieselben zwei zurück. Eine wachsende Liste ist das Gegenteil von /// zwischenspeicherbar — ein fertiges Segment dagegen ändert sich nie. /// [Test] public async Task Die_Playlist_wird_nicht_zwischengespeichert() { Film("Zwischenspeicher (2020)"); var (client, datei) = await Eingelesen(); var strom = await Strom(client, datei.Id, Browser()); var liste = await client.GetAsync(strom.Url); var segment = await client.GetAsync($"/api/v1/stream/{strom.SessionId}/seg00000.m4s"); Assert.Multiple(() => { Assert.That(liste.Headers.CacheControl?.NoStore, Is.True, "eine wachsende Liste darf nirgends liegenbleiben"); Assert.That(liste.Content.Headers.LastModified, Is.Null, "sonst fragt der Browser bedingt nach und bekommt 304 auf eine veraltete Liste"); Assert.That(segment.Headers.CacheControl?.MaxAge, Is.Not.Null, "ein fertiges Segment darf liegenbleiben — das spart den Weg beim Zurückspringen"); }); } /// Ein unbekannter Bildcodec führt zum Rechnen, nicht zum Umpacken. /// /// /// Das war der Fehler, an dem jede Wiedergabe scheiterte. Der /// Lieferweg für Downloads antwortete auch hier mit „umpacken" — und /// Umpacken fasst das Bild nicht an. Der Browser bekam eine tadellose MP4 /// mit einem Codec, den er nicht kennt. /// [Test] public async Task Ein_unbekannter_Bildcodec_wird_gerechnet() { Film("Rechnen (2020)"); var (client, datei) = await Eingelesen(); // Ein Gerät, das H.264 **nicht** kann — der kürzeste Weg zu „muss gerechnet werden". var strom = await Strom(client, datei.Id, new StreamRequest( Containers: ["mp4"], VideoCodecs: ["vp9"], AudioCodecs: ["aac"])); Assert.That(strom.Method, Is.EqualTo("transcode")); } /// Eine kleinere Auflösung wird wirklich kleiner. [Test] public async Task Eine_kleinere_Stufe_wird_herunterskaliert() { Film("Gross (2020)", hoehe: 720); var (client, datei) = await Eingelesen(); var strom = await Strom(client, datei.Id, Browser(hoehe: 480)); Assert.Multiple(() => { Assert.That(strom.Method, Is.EqualTo("transcode")); Assert.That(strom.Height, Is.EqualTo(480)); }); } /// Hochrechnen ist keine Stufe. [Test] public async Task Eine_Stufe_ueber_der_Quelle_bleibt_beim_Umpacken() { Film("Klein (2020)", hoehe: 240); var (client, datei) = await Eingelesen(); var strom = await Strom(client, datei.Id, Browser(hoehe: 1080)); Assert.Multiple(() => { Assert.That(strom.Height, Is.Null); Assert.That(strom.Method, Is.EqualTo("remux"), "240p auf 1080p zu rechnen kostet Last für kein besseres Bild"); }); } /// Der Spieler erfährt, welche Tonspur wirklich läuft. /// /// /// Nicht die gewünschte. Vorher meldete der Server zurück, was /// gefragt worden war — meist null —, und im Menü stand bei keiner /// Spur ein Haken, obwohl eine davon lief. /// [Test] public async Task Der_Strom_nennt_die_benutzte_Tonspur() { Film("Tonwahl (2020)"); var (client, datei) = await Eingelesen(); var ohneWunsch = await Strom(client, datei.Id, Browser()); var mitWunsch = await Strom(client, datei.Id, Browser(ton: 2)); var unsinn = await Strom(client, datei.Id, Browser(ton: 99)); Assert.Multiple(() => { Assert.That(ohneWunsch.AudioStream, Is.EqualTo(1), "ohne Wunsch die erste"); Assert.That(mitWunsch.AudioStream, Is.EqualTo(2)); Assert.That(unsinn.AudioStream, Is.EqualTo(1), "eine Spur, die es nicht gibt, macht den Film nicht stumm"); }); } /// Der Einstiegspunkt kommt zurück — und zwar der echte. /// /// /// /// Sonst ginge jeder gemeldete Stand um den Vorlauf daneben. Der /// Strom fängt bei null an, der Film bei Minute 30. /// /// /// Und „echt" heisst: beim Schlüsselbild. Dieser Test stand vorher /// auf „genau zwei Sekunden" und hielt damit einen Fehler fest. Ein /// kopierter Bildstrom lässt sich nicht mitten in einer Bildgruppe /// aufschneiden: der Strom fängt beim Schlüsselbild davor an, und wer /// trotzdem „zwei" zurückmeldet, verschiebt die Uhr des Spielers um den /// Unterschied. Diese Testdatei hat genau ein Schlüsselbild, bei null — /// also ist null die richtige Antwort. /// /// [Test] public async Task Der_Einstiegspunkt_steht_in_der_Antwort() { Film("Sprung (2020)", sekunden: 4); var (client, datei) = await Eingelesen(); var strom = await Strom(client, datei.Id, Browser(ab: 2)); Assert.That(strom.StartSeconds, Is.LessThanOrEqualTo(2), "Weiter als gewünscht darf der Strom nie anfangen — das überspränge Film."); Assert.That(strom.StartSeconds, Is.EqualTo(0), "Diese Datei hat nur ein Schlüsselbild, bei null."); } /// Schliessen räumt auf — sonst füllt sich der Kratzplatz. [Test] public async Task Schliessen_raeumt_die_Sitzung_weg() { Film("Aufraeumen (2020)"); var (client, datei) = await Eingelesen(); var strom = await Strom(client, datei.Id, Browser()); (await client.GetAsync(strom.Url)).EnsureSuccessStatusCode(); var weg = await client.DeleteAsync($"/api/v1/stream/{strom.SessionId}"); var danach = await client.GetAsync(strom.Url); Assert.Multiple(() => { Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); Assert.That(danach.StatusCode, Is.EqualTo(HttpStatusCode.NotFound), "eine beendete Sitzung gibt es nicht mehr"); }); } /// Ein Pfad aus der Anfrage darf nie ans Dateisystem durchgereicht werden. [Test] public async Task Aus_der_Sitzung_kommt_man_nicht_heraus() { Film("Sicher (2020)"); var (client, datei) = await Eingelesen(); var strom = await Strom(client, datei.Id, Browser()); var hinaus = await client.GetAsync($"/api/v1/stream/{strom.SessionId}/..%2F..%2Fflaro.db"); var fremd = await client.GetAsync($"/api/v1/stream/{strom.SessionId}/index.m3u8x"); Assert.Multiple(() => { Assert.That(hinaus.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); Assert.That(fremd.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); }); } /// Ohne Anmeldung kommt niemand an einen Strom. [Test] public async Task Ohne_Anmeldung_gibt_es_keinen_Strom() { Film("Zutritt (2020)"); var (client, datei) = await Eingelesen(); var strom = await Strom(client, datei.Id, Browser()); var ohne = _server!.OhneGedaechtnis(); var antwort = await ohne.GetAsync(strom.Url); Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); } /// Eine Sitzung, die es nicht gibt, ist eine 404 und kein Absturz. [Test] public async Task Eine_unbekannte_Sitzung_ist_nicht_gefunden() { Film("Unbekannt (2020)"); var (client, _) = await Eingelesen(); var antwort = await client.GetAsync($"/api/v1/stream/{Guid.CreateVersion7()}/index.m3u8"); Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); } }