using System.Diagnostics; using System.Globalization; 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; } /// Derselbe Film, dazu eine Textspur auf Deutsch. /// /// /// Für die Hauptliste braucht es etwas, das darin stehen kann. Eine /// Bildspur (PGS) wäre dafür untauglich und soll ausdrücklich fehlen — /// die prüft /// nicht, weil ffmpeg sie nicht nebenbei erzeugt. /// private string FilmMitUntertitel(string name, int sekunden = 4) { var srt = Path.Combine(_bestand, "text.srt"); File.WriteAllText(srt, "1\n00:00:00,500 --> 00:00:02,000\nEin Satz.\n\n" + "2\n00:00:02,500 --> 00:00:03,500\nNoch einer.\n"); 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=320x240:rate=10", "-f", "lavfi", "-i", $"sine=frequency=440:duration={sekunden}", "-i", srt, "-map", "0:v", "-map", "1:a", "-map", "2:s", "-metadata:s:s:0", "language=deu", "-metadata:s:s:0", "title=Deutsch", "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-c:a", "aac", "-c:s", "srt", 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); File.Delete(srt); 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)); } // --- Die Hauptliste, für Abspieler ohne eigenen Weg zu Untertiteln --- /// Die Hauptliste nennt die Bildspur und eine Bandbreite. /// /// /// BANDWIDTH ist Pflicht. Eine Hauptliste ohne diese Angabe weist /// AVPlayer ab — und zwar wortlos, mit einem schwarzen Bild. /// [Test] public async Task Die_Hauptliste_nennt_die_Bildspur() { FilmMitUntertitel("Hauptliste (2020)"); var (client, datei) = await Eingelesen(); var strom = await Strom(client, datei.Id, Browser()); (await client.GetAsync( $"/api/v1/files/{datei.Id}/subtitles/{strom.SubtitleStreams.Single().Index}.vtt")) .EnsureSuccessStatusCode(); // Noch einmal bestellen: die Hauptliste gibt es erst, wenn eine Spur // abholbar ist — vorher wäre sie eine Hülle um die Medienliste. strom = await Strom(client, datei.Id, Browser()); Assert.That(strom.MasterUrl, Is.Not.Null); var liste = await client.GetStringAsync(strom.MasterUrl!); Assert.Multiple(() => { Assert.That(liste, Does.StartWith("#EXTM3U")); Assert.That(liste, Does.Contain("#EXT-X-STREAM-INF:BANDWIDTH=")); Assert.That(liste, Does.Contain("index.m3u8")); }); } /// Jede Textspur steht als eigene Spur in der Hauptliste. /// /// /// Das ist der ganze Grund für diese Liste. AVPlayer kennt keinen /// Weg, einer laufenden Wiedergabe eine Textspur von aussen beizulegen; /// was er findet, findet er hier oder gar nicht. /// [Test] public async Task Die_Hauptliste_nennt_jede_Textspur() { FilmMitUntertitel("Mit Text (2020)"); var (client, datei) = await Eingelesen(); var strom = await Strom(client, datei.Id, Browser()); var spur = strom.SubtitleStreams.Single(); // **Erst holen, dann fragen.** In der Hauptliste steht nur, was // sofort abholbar ist, und es gibt sie überhaupt nur dann; der Abruf // der Spur wartet, bis sie aus der Datei gezogen ist. Ohne diesen // Schritt hinge die Prüfung daran, wie schnell der Rechner ist. (await client.GetAsync( $"/api/v1/files/{datei.Id}/subtitles/{spur.Index}.vtt")).EnsureSuccessStatusCode(); var zweiter = await Strom(client, datei.Id, Browser()); var liste = await client.GetStringAsync(zweiter.MasterUrl!); Assert.Multiple(() => { Assert.That(liste, Does.Contain("#EXT-X-MEDIA:TYPE=SUBTITLES")); Assert.That(liste, Does.Contain($"URI=\"untertitel/{spur.Index}.m3u8\"")); Assert.That(liste, Does.Contain("SUBTITLES=\"untertitel\"")); Assert.That(liste, Does.Contain("DEFAULT=NO"), "Was gezeigt wird, entscheidet der Spieler — nicht die Liste."); }); } /// Die Spurliste zeigt auf die Spur, mit dem Vorlauf der Sitzung. /// /// /// /// Ohne ab läge jeder Satz in der Zukunft. Die Zeitstempel /// in der Datei zählen ab Dateianfang, der Strom ab null. /// /// /// Verglichen wird mit dem gemeldeten Vorlauf, nicht mit dem /// gewünschten — und das ist keine Bequemlichkeit. Beim Kopieren /// beginnt ffmpeg am Schlüsselbild vor dem Wunsch; in einem Prüfclip von /// vier Sekunden ist das die Null. Der Untertitel muss zu dem passen, was /// wirklich läuft. /// /// [Test] public async Task Die_Spurliste_traegt_den_Vorlauf_der_Sitzung() { FilmMitUntertitel("Mit Vorlauf (2020)"); var (client, datei) = await Eingelesen(); var strom = await Strom(client, datei.Id, Browser(ab: 2)); var spur = strom.SubtitleStreams.Single(); var liste = await client.GetStringAsync( $"/api/v1/stream/{strom.SessionId}/untertitel/{spur.Index}.m3u8"); var vorlauf = strom.StartSeconds.ToString("0.###", CultureInfo.InvariantCulture); Assert.Multiple(() => { Assert.That(liste, Does.StartWith("#EXTM3U")); Assert.That(liste, Does.Contain("#EXT-X-ENDLIST"), "Eine Textspur ist vollständig, sobald es sie gibt — sie wächst nicht."); Assert.That(liste, Does.Contain( $"/api/v1/files/{datei.Id}/subtitles/{spur.Index}.vtt?ab={vorlauf}")); }); } /// Eine Spur, die es nicht gibt, gibt es auch hier nicht. [Test] public async Task Eine_unbekannte_Spur_hat_keine_Liste() { FilmMitUntertitel("Ohne diese Spur (2020)"); var (client, datei) = await Eingelesen(); var strom = await Strom(client, datei.Id, Browser()); var antwort = await client.GetAsync($"/api/v1/stream/{strom.SessionId}/untertitel/99.m3u8"); Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); } /// Ohne Sitzung keine Liste — auch nicht die Hauptliste. [Test] public async Task Ohne_Sitzung_gibt_es_keine_Hauptliste() { Film("Keine Sitzung (2020)"); var (client, _) = await Eingelesen(); var antwort = await client.GetAsync($"/api/v1/stream/{Guid.NewGuid()}/master.m3u8"); Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); } /// Eine Direktwiedergabe braucht keine Hauptliste. /// /// /// Die Datei bringt ihre Spuren selbst mit; eine Liste darum herum wäre /// eine Hülle um nichts. /// [Test] public async Task Eine_Direktwiedergabe_hat_keine_Hauptliste() { Film("Direkt (2020)"); var (client, datei) = await Eingelesen(); var strom = await Strom(client, datei.Id, new StreamRequest( Containers: ["matroska", "mkv"], VideoCodecs: ["h264"], AudioCodecs: ["aac", "eac3"])); Assume.That(strom.Method, Is.EqualTo("direct")); Assert.That(strom.MasterUrl, Is.Null); } /// Was die App bestellt, lässt sich auch abholen. /// /// /// Die App schickt keine Codec-Listen — dann nimmt der Server /// Apples Profil, und das ist hier richtig: dieses Gerät ist Apple. Der /// Weg danach ist aber ein anderer als der des Browsers, und genau dort /// kam eine 500 heraus. Diese Prüfung geht ihn ganz ab: bestellen, /// Hauptliste holen, Medienliste holen, erstes Stück holen. /// [Test] public async Task Der_Weg_der_App_fuehrt_bis_zum_ersten_Stueck() { FilmMitUntertitel("Wie die App (2020)"); var (client, datei) = await Eingelesen(); var strom = await Strom(client, datei.Id, new StreamRequest( Containers: null, VideoCodecs: null, AudioCodecs: null, StartSeconds: 0)); Assert.That(strom.Method, Is.Not.EqualTo("direct"), "Eine MKV ist für Apple nichts zum Durchreichen."); var medien = await client.GetAsync(strom.Url); Assert.That(medien.StatusCode, Is.EqualTo(HttpStatusCode.OK), await medien.Content.ReadAsStringAsync()); var liste = await medien.Content.ReadAsStringAsync(); var stueck = liste.Split('\n').FirstOrDefault(z => z.Length > 0 && !z.StartsWith('#')); Assert.That(stueck, Is.Not.Null, liste); var teil = await client.GetAsync($"/api/v1/stream/{strom.SessionId}/{stueck}"); Assert.That(teil.StatusCode, Is.EqualTo(HttpStatusCode.OK)); } }