Eine Hauptliste, damit auch AVPlayer die Untertitel findet
`index.m3u8` ist eine Medienliste: eine Reihe von Schnipseln, sonst nichts. Dem Browser reicht das, weil er die Untertitel als eigene Spur ans Videoelement gehängt bekommt. AVPlayer kann das nicht — dort gibt es keinen Weg, einer laufenden Wiedergabe eine Textspur von aussen beizulegen. Was er findet, findet er in der Abspielliste oder gar nicht. Also eine Hauptliste: dieselbe Medienliste als Bildspur, daneben je eine Textspur als eigene Liste mit genau einem Stück — unsere WebVTT-Datei über den ganzen Rest des Films. Der Vorlauf der Sitzung hängt daran, denn die Zeitstempel in der Datei zählen ab Dateianfang und der Strom ab null. Für den Browser ändert sich nichts; er holt weiterhin `index.m3u8`. Eine Direktwiedergabe bekommt keine Hauptliste: die Datei bringt ihre Spuren selbst mit, und eine Liste darum herum wäre eine Hülle um nichts. Vorarbeit für die App, die bisher auf eine fertig umgepackte MP4 wartet — dreissig Sekunden, wo das Web nach einer anfängt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
912969d829
commit
4a5576f939
@@ -770,6 +770,18 @@ public sealed record SpurAuskunft(int Index, string Label, string? Language, boo
|
|||||||
public sealed record StreamResponse(
|
public sealed record StreamResponse(
|
||||||
Guid SessionId,
|
Guid SessionId,
|
||||||
string Url,
|
string Url,
|
||||||
|
|
||||||
|
/// <summary>Dieselbe Wiedergabe als Hauptliste, mit Untertitelspuren.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Für Abspieler, die ihre Untertitel aus HLS beziehen.</b> Der
|
||||||
|
/// Browser hängt sie als eigene Spur ans Videoelement und braucht nur
|
||||||
|
/// <see cref="Url"/>; AVPlayer kennt diesen Weg nicht — dort gibt es
|
||||||
|
/// keine Möglichkeit, einer laufenden Wiedergabe eine Textspur von
|
||||||
|
/// aussen beizulegen. Bei einer Direktwiedergabe steht hier nichts: dort
|
||||||
|
/// liegen die Spuren in der Datei selbst.
|
||||||
|
/// </remarks>
|
||||||
|
string? MasterUrl,
|
||||||
string Method,
|
string Method,
|
||||||
double StartSeconds,
|
double StartSeconds,
|
||||||
int? Height,
|
int? Height,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Globalization;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Flaro.Core.Delivery;
|
using Flaro.Core.Delivery;
|
||||||
@@ -26,6 +27,34 @@ namespace Flaro.Api.Endpoints;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class StreamEndpoints
|
public static class StreamEndpoints
|
||||||
{
|
{
|
||||||
|
/// <summary>Wie die Untertitelgruppe in der Hauptliste heisst.</summary>
|
||||||
|
private const string Untertitelgruppe = "untertitel";
|
||||||
|
|
||||||
|
/// <summary>Was in der Hauptliste als Bandbreite steht.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Eine Zahl, die niemand liest, und trotzdem Pflicht.</b> HLS
|
||||||
|
/// verlangt sie auf jedem <c>EXT-X-STREAM-INF</c>, damit ein Spieler
|
||||||
|
/// zwischen Stufen wählen kann. Es gibt hier nur eine Stufe — die Wahl
|
||||||
|
/// trifft der Benutzer vorher im Menü —, also ist jede Angabe so gut wie
|
||||||
|
/// jede andere. Acht Megabit sind reichlich und schrecken keinen Spieler
|
||||||
|
/// ab, der sie für eine Mindestanforderung hält.
|
||||||
|
/// </remarks>
|
||||||
|
private const int Bandbreite = 8_000_000;
|
||||||
|
|
||||||
|
/// <summary>Was in einer Abspielliste zwischen Anführungszeichen stehen darf.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// In einem M3U-Attribut gibt es kein Maskieren: ein Anführungszeichen
|
||||||
|
/// beendet den Wert, und der Rest der Zeile wird zu Unsinn. Ein Titel wie
|
||||||
|
/// <c>Kommentar "Regie"</c> stünde sonst als halbe Zeile da.
|
||||||
|
/// </remarks>
|
||||||
|
private static string M3uText(string roh) =>
|
||||||
|
roh.Replace("\"", "'", StringComparison.Ordinal)
|
||||||
|
.Replace("\n", " ", StringComparison.Ordinal)
|
||||||
|
.Replace("\r", " ", StringComparison.Ordinal)
|
||||||
|
.Replace(",", " ", StringComparison.Ordinal);
|
||||||
|
|
||||||
public static void MapStream(this IEndpointRouteBuilder app)
|
public static void MapStream(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
// **Eine Sitzung anlegen.** Der Rumpf trägt, was das Gerät kann und
|
// **Eine Sitzung anlegen.** Der Rumpf trägt, was das Gerät kann und
|
||||||
@@ -99,6 +128,120 @@ public static class StreamEndpoints
|
|||||||
}
|
}
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
// **Die Hauptliste — für Abspieler, die ihre Untertitel aus HLS
|
||||||
|
// beziehen.**
|
||||||
|
//
|
||||||
|
// `index.m3u8` ist eine Medienliste: eine Reihe von Schnipseln, sonst
|
||||||
|
// nichts. Der Browser braucht nicht mehr, weil er die Untertitel als
|
||||||
|
// eigene Spur ans Videoelement gehängt bekommt. AVPlayer kann das
|
||||||
|
// nicht — dort gibt es keinen Weg, einer laufenden Wiedergabe eine
|
||||||
|
// Textspur von aussen beizulegen. Was er findet, findet er in der
|
||||||
|
// Abspielliste oder gar nicht.
|
||||||
|
//
|
||||||
|
// Also hier: dieselbe Medienliste als Bildspur, daneben je eine
|
||||||
|
// Untertitelspur. Für den Browser ändert sich nichts; er holt
|
||||||
|
// weiterhin `index.m3u8`.
|
||||||
|
app.MapGet("/api/v1/stream/{sitzung:guid}/master.m3u8", async (
|
||||||
|
Guid sitzung, HttpResponse antwort, Streamkoordinator koordinator,
|
||||||
|
FlaroContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
// Dieselbe Begründung wie bei der Medienliste darunter: eine
|
||||||
|
// Liste, die sich ändern kann, darf nicht zwischengespeichert
|
||||||
|
// werden.
|
||||||
|
antwort.Headers.CacheControl = "no-store, no-cache, must-revalidate";
|
||||||
|
if (koordinator.Sitzung(sitzung) is not { } laufend) return Results.NotFound();
|
||||||
|
koordinator.Lebenszeichen(sitzung);
|
||||||
|
|
||||||
|
var spuren = await db.MediaStreams.AsNoTracking()
|
||||||
|
.Where(x => x.MediaFileId == laufend.DateiId && x.Kind == MediaStreamKind.Subtitle)
|
||||||
|
.OrderBy(x => x.Index)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var text = spuren
|
||||||
|
.Where(x => x.Codec is null || !Untertitelvorrat.IstBild(x.Codec))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var liste = new StringBuilder();
|
||||||
|
liste.AppendLine("#EXTM3U");
|
||||||
|
liste.AppendLine("#EXT-X-VERSION:6");
|
||||||
|
|
||||||
|
foreach (var spur in text)
|
||||||
|
{
|
||||||
|
// **Nichts ist voreingestellt.** `DEFAULT=YES` hiesse: dieser
|
||||||
|
// Untertitel läuft, ohne dass jemand ihn bestellt hat. Was
|
||||||
|
// gezeigt wird, entscheidet der Spieler.
|
||||||
|
liste.Append("#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"")
|
||||||
|
.Append(Untertitelgruppe)
|
||||||
|
.Append("\",NAME=\"").Append(M3uText(Benennung(spur))).Append('"');
|
||||||
|
|
||||||
|
if (spur.Language is { Length: > 0 } sprache)
|
||||||
|
liste.Append(",LANGUAGE=\"").Append(M3uText(sprache)).Append('"');
|
||||||
|
|
||||||
|
liste.Append(",DEFAULT=NO,AUTOSELECT=NO,FORCED=")
|
||||||
|
.Append(spur.IsForced ? "YES" : "NO")
|
||||||
|
.Append(",URI=\"untertitel/").Append(spur.Index).Append(".m3u8\"")
|
||||||
|
.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// **BANDWIDTH ist Pflicht, auch wenn es nur eine Stufe gibt.**
|
||||||
|
// Eine Hauptliste ohne diese Angabe weist AVPlayer ab.
|
||||||
|
liste.Append("#EXT-X-STREAM-INF:BANDWIDTH=").Append(Bandbreite);
|
||||||
|
if (text.Count > 0) liste.Append(",SUBTITLES=\"").Append(Untertitelgruppe).Append('"');
|
||||||
|
liste.AppendLine();
|
||||||
|
liste.AppendLine("index.m3u8");
|
||||||
|
|
||||||
|
return Results.Text(liste.ToString(), "application/vnd.apple.mpegurl", Encoding.UTF8);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
// **Eine Untertitelspur als Abspielliste.**
|
||||||
|
//
|
||||||
|
// HLS kennt Text nur als eigene Spur mit eigener Liste. Unsere Spur
|
||||||
|
// ist eine einzige WebVTT-Datei über den ganzen Film — also eine
|
||||||
|
// Liste mit genau einem Stück, so lang wie der Rest des Films.
|
||||||
|
//
|
||||||
|
// **`ab` muss mit.** Die Zeitstempel in der Datei zählen ab
|
||||||
|
// Dateianfang, der Strom ab null. Ohne diesen Wert läge bei
|
||||||
|
// „Fortsetzen ab 8:37" jeder Satz acht Minuten in der Zukunft.
|
||||||
|
app.MapGet("/api/v1/stream/{sitzung:guid}/untertitel/{spur:int}.m3u8", async (
|
||||||
|
Guid sitzung, int spur, HttpResponse antwort,
|
||||||
|
Streamkoordinator koordinator, FlaroContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
antwort.Headers.CacheControl = "no-store, no-cache, must-revalidate";
|
||||||
|
if (koordinator.Sitzung(sitzung) is not { } laufend) return Results.NotFound();
|
||||||
|
koordinator.Lebenszeichen(sitzung);
|
||||||
|
|
||||||
|
var textspuren = await Untertitelvorrat.TextspurenAsync(db, laufend.DateiId, ct);
|
||||||
|
if (!textspuren.Contains(spur)) return Results.NotFound();
|
||||||
|
|
||||||
|
var datei = await db.MediaFiles.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(f => f.Id == laufend.DateiId, ct);
|
||||||
|
if (datei is null) return Results.NotFound();
|
||||||
|
|
||||||
|
// Ohne bekannte Laufzeit eine grosszügige Länge: zu kurz hiesse,
|
||||||
|
// dass der Rest des Films ohne Untertitel dasteht.
|
||||||
|
var rest = datei.Duration is { TotalSeconds: > 0 } dauer
|
||||||
|
? Math.Max(1, dauer.TotalSeconds - laufend.Ab)
|
||||||
|
: 86400;
|
||||||
|
|
||||||
|
var ab = laufend.Ab.ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
|
var laenge = rest.ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
var liste =
|
||||||
|
$"""
|
||||||
|
#EXTM3U
|
||||||
|
#EXT-X-VERSION:3
|
||||||
|
#EXT-X-PLAYLIST-TYPE:VOD
|
||||||
|
#EXT-X-TARGETDURATION:{Math.Ceiling(rest)}
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:0
|
||||||
|
#EXTINF:{laenge},
|
||||||
|
/api/v1/files/{laufend.DateiId}/subtitles/{spur}.vtt?ab={ab}
|
||||||
|
#EXT-X-ENDLIST
|
||||||
|
|
||||||
|
""";
|
||||||
|
|
||||||
|
return Results.Text(liste, "application/vnd.apple.mpegurl", Encoding.UTF8);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
// **Die Playlist.** Sie wächst, während ffmpeg schreibt.
|
// **Die Playlist.** Sie wächst, während ffmpeg schreibt.
|
||||||
app.MapGet("/api/v1/stream/{sitzung:guid}/index.m3u8", async (
|
app.MapGet("/api/v1/stream/{sitzung:guid}/index.m3u8", async (
|
||||||
Guid sitzung, HttpResponse antwort, Streamkoordinator koordinator, CancellationToken ct) =>
|
Guid sitzung, HttpResponse antwort, Streamkoordinator koordinator, CancellationToken ct) =>
|
||||||
@@ -294,6 +437,11 @@ public static class StreamEndpoints
|
|||||||
// dass es nichts zu beenden und nichts aufzuräumen gibt.
|
// dass es nichts zu beenden und nichts aufzuräumen gibt.
|
||||||
SessionId: Guid.Empty,
|
SessionId: Guid.Empty,
|
||||||
Url: $"/api/v1/files/{datei}/download",
|
Url: $"/api/v1/files/{datei}/download",
|
||||||
|
|
||||||
|
// **Keine Hauptliste.** Eine durchgereichte Datei bringt ihre
|
||||||
|
// Spuren selbst mit; eine Liste darum herum wäre eine Hülle um
|
||||||
|
// nichts.
|
||||||
|
MasterUrl: null,
|
||||||
Method: "direct",
|
Method: "direct",
|
||||||
|
|
||||||
// **Null, nicht `ab`.** Bei einer durchgereichten Datei springt
|
// **Null, nicht `ab`.** Bei einer durchgereichten Datei springt
|
||||||
@@ -345,6 +493,7 @@ public static class StreamEndpoints
|
|||||||
return new StreamResponse(
|
return new StreamResponse(
|
||||||
SessionId: sitzung.Kennung,
|
SessionId: sitzung.Kennung,
|
||||||
Url: $"/api/v1/stream/{sitzung.Kennung}/index.m3u8",
|
Url: $"/api/v1/stream/{sitzung.Kennung}/index.m3u8",
|
||||||
|
MasterUrl: $"/api/v1/stream/{sitzung.Kennung}/master.m3u8",
|
||||||
Method: sitzung.Art switch
|
Method: sitzung.Art switch
|
||||||
{
|
{
|
||||||
Stromart.Direkt => "direct",
|
Stromart.Direkt => "direct",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using Flaro.Api.Endpoints;
|
using Flaro.Api.Endpoints;
|
||||||
@@ -78,6 +79,51 @@ public class StreamTests
|
|||||||
return ziel;
|
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()
|
private async Task<(HttpClient Client, MediaFileResponse Datei)> Eingelesen()
|
||||||
{
|
{
|
||||||
var server = _server = new FlaroServer();
|
var server = _server = new FlaroServer();
|
||||||
@@ -374,4 +420,142 @@ public class StreamTests
|
|||||||
var antwort = await client.GetAsync($"/api/v1/stream/{Guid.CreateVersion7()}/index.m3u8");
|
var antwort = await client.GetAsync($"/api/v1/stream/{Guid.CreateVersion7()}/index.m3u8");
|
||||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user