Files
Flaro/server/tests/Flaro.Api.Tests/StreamTests.cs
T
ScarriffleandClaude Opus 5 6ef84b47cf Den Weg der App einmal ganz abgehen
Die App schickt keine Codec-Listen; dann nimmt der Server Apples Profil.
Das führt zu einer anderen Entscheidung als beim Browser — dort wird
umgerechnet, hier umgepackt — und damit auf einen Weg, den keine Prüfung
abging. Jetzt schon: bestellen, Hauptliste, Medienliste, erstes Stück.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 17:01:50 +02:00

598 lines
24 KiB
C#

using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using Flaro.Api.Endpoints;
namespace Flaro.Api.Tests;
/// <summary>Wiedergabe, die sofort anfängt — von der Anfrage bis zum Segment.</summary>
///
/// <remarks>
/// <b>Mit echtem ffmpeg, wie bei den Lieferungsprüfungen.</b> Ob eine Playlist
/// entsteht und ob darin Segmente stehen, lässt sich nicht behaupten; es muss
/// jemand nachsehen.
/// </remarks>
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) { }
}
/// <summary>Was ein gewöhnlicher Browser kann — kein HEVC, kein EAC3.</summary>
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);
/// <summary>Eine MKV mit zwei Tonspuren, die zweite in einem Codec für Apple.</summary>
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;
}
/// <summary>Derselbe Film, dazu eine Textspur auf Deutsch.</summary>
///
/// <remarks>
/// 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 <see cref="Eine_Bildspur_steht_nicht_in_der_Hauptliste"/>
/// nicht, weil ffmpeg sie nicht nebenbei erzeugt.
/// </remarks>
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<LibraryResponse>())!;
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
for (var i = 0; i < 200; i++)
{
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek.Id}/scan");
if (stand!.State == "finished") break;
await Task.Delay(50);
}
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/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<StreamResponse> 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<StreamResponse>())!;
}
// ------------------------------------------------------------------
/// <summary>Eine MKV wird für den Browser umgepackt, nicht neu gerechnet.</summary>
[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));
});
}
/// <summary>Die Playlist kommt, sobald das erste Segment da ist.</summary>
///
/// <remarks>
/// <b>Und nicht als 204.</b> 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.
/// </remarks>
[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));
});
}
/// <summary>Die Liste darf nicht zwischengespeichert werden, die Segmente schon.</summary>
///
/// <remarks>
/// <b>Aus einem Fehler gelernt.</b> Die Wiedergabe blieb nach zwanzig
/// Sekunden stehen, obwohl auf der Platte längst der ganze Rest lag:
/// <c>Results.File</c> setzte <c>Last-Modified</c>, 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.
/// </remarks>
[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");
});
}
/// <summary>Ein unbekannter Bildcodec führt zum Rechnen, nicht zum Umpacken.</summary>
///
/// <remarks>
/// <b>Das war der Fehler, an dem jede Wiedergabe scheiterte.</b> 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.
/// </remarks>
[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"));
}
/// <summary>Eine kleinere Auflösung wird wirklich kleiner.</summary>
[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));
});
}
/// <summary>Hochrechnen ist keine Stufe.</summary>
[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");
});
}
/// <summary>Der Spieler erfährt, welche Tonspur wirklich läuft.</summary>
///
/// <remarks>
/// <b>Nicht die gewünschte.</b> Vorher meldete der Server zurück, was
/// gefragt worden war — meist <c>null</c> —, und im Menü stand bei keiner
/// Spur ein Haken, obwohl eine davon lief.
/// </remarks>
[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");
});
}
/// <summary>Der Einstiegspunkt kommt zurück — und zwar der echte.</summary>
///
/// <remarks>
/// <para>
/// <b>Sonst ginge jeder gemeldete Stand um den Vorlauf daneben.</b> Der
/// Strom fängt bei null an, der Film bei Minute 30.
/// </para>
/// <para>
/// <b>Und „echt" heisst: beim Schlüsselbild.</b> 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.
/// </para>
/// </remarks>
[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.");
}
/// <summary>Schliessen räumt auf — sonst füllt sich der Kratzplatz.</summary>
[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");
});
}
/// <summary>Ein Pfad aus der Anfrage darf nie ans Dateisystem durchgereicht werden.</summary>
[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));
});
}
/// <summary>Ohne Anmeldung kommt niemand an einen Strom.</summary>
[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));
}
/// <summary>Eine Sitzung, die es nicht gibt, ist eine 404 und kein Absturz.</summary>
[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 ---
/// <summary>Die Hauptliste nennt die Bildspur und eine Bandbreite.</summary>
///
/// <remarks>
/// <b>BANDWIDTH ist Pflicht.</b> Eine Hauptliste ohne diese Angabe weist
/// AVPlayer ab — und zwar wortlos, mit einem schwarzen Bild.
/// </remarks>
[Test]
public async Task Die_Hauptliste_nennt_die_Bildspur()
{
Film("Hauptliste (2020)");
var (client, datei) = await Eingelesen();
var strom = await Strom(client, datei.Id, Browser());
Assert.That(strom.MasterUrl, Is.Not.Null, "Eine Sitzung hat eine Hauptliste.");
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"));
});
}
/// <summary>Jede Textspur steht als eigene Spur in der Hauptliste.</summary>
///
/// <remarks>
/// <b>Das ist der ganze Grund für diese Liste.</b> AVPlayer kennt keinen
/// Weg, einer laufenden Wiedergabe eine Textspur von aussen beizulegen;
/// was er findet, findet er hier oder gar nicht.
/// </remarks>
[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();
var liste = await client.GetStringAsync(strom.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.");
});
}
/// <summary>Die Spurliste zeigt auf die Spur, mit dem Vorlauf der Sitzung.</summary>
///
/// <remarks>
/// <para>
/// <b>Ohne <c>ab</c> läge jeder Satz in der Zukunft.</b> Die Zeitstempel
/// in der Datei zählen ab Dateianfang, der Strom ab null.
/// </para>
/// <para>
/// <b>Verglichen wird mit dem gemeldeten Vorlauf, nicht mit dem
/// gewünschten</b> — 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.
/// </para>
/// </remarks>
[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}"));
});
}
/// <summary>Eine Spur, die es nicht gibt, gibt es auch hier nicht.</summary>
[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));
}
/// <summary>Ohne Sitzung keine Liste — auch nicht die Hauptliste.</summary>
[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));
}
/// <summary>Eine Direktwiedergabe braucht keine Hauptliste.</summary>
///
/// <remarks>
/// Die Datei bringt ihre Spuren selbst mit; eine Liste darum herum wäre
/// eine Hülle um nichts.
/// </remarks>
[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);
}
/// <summary>Was die App bestellt, lässt sich auch abholen.</summary>
///
/// <remarks>
/// <b>Die App schickt keine Codec-Listen</b> — 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.
/// </remarks>
[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.MasterUrl, Is.Not.Null, "Eine MKV ist für Apple nichts zum Durchreichen.");
var haupt = await client.GetAsync(strom.MasterUrl!);
Assert.That(haupt.StatusCode, Is.EqualTo(HttpStatusCode.OK),
await haupt.Content.ReadAsStringAsync());
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));
}
}