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, damit der Spieler richtig zählt.
///
///
/// Sonst ginge jeder gemeldete Stand um den Vorlauf daneben. Der
/// Strom fängt bei null an, der Film bei Minute 30.
///
[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.EqualTo(2));
}
/// 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));
}
}