diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs
index 661aeba..3c5e373 100644
--- a/server/src/Flaro.Api/Endpoints/Contracts.cs
+++ b/server/src/Flaro.Api/Endpoints/Contracts.cs
@@ -770,6 +770,18 @@ public sealed record SpurAuskunft(int Index, string Label, string? Language, boo
public sealed record StreamResponse(
Guid SessionId,
string Url,
+
+ /// Dieselbe Wiedergabe als Hauptliste, mit Untertitelspuren.
+ ///
+ ///
+ /// Für Abspieler, die ihre Untertitel aus HLS beziehen. Der
+ /// Browser hängt sie als eigene Spur ans Videoelement und braucht nur
+ /// ; 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.
+ ///
+ string? MasterUrl,
string Method,
double StartSeconds,
int? Height,
diff --git a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs
index 0e10876..7773a9e 100644
--- a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using System.Security.Claims;
using System.Text;
using Flaro.Core.Delivery;
@@ -26,6 +27,34 @@ namespace Flaro.Api.Endpoints;
///
public static class StreamEndpoints
{
+ /// Wie die Untertitelgruppe in der Hauptliste heisst.
+ private const string Untertitelgruppe = "untertitel";
+
+ /// Was in der Hauptliste als Bandbreite steht.
+ ///
+ ///
+ /// Eine Zahl, die niemand liest, und trotzdem Pflicht. HLS
+ /// verlangt sie auf jedem EXT-X-STREAM-INF, 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.
+ ///
+ private const int Bandbreite = 8_000_000;
+
+ /// Was in einer Abspielliste zwischen Anführungszeichen stehen darf.
+ ///
+ ///
+ /// 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
+ /// Kommentar "Regie" stünde sonst als halbe Zeile da.
+ ///
+ 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)
{
// **Eine Sitzung anlegen.** Der Rumpf trägt, was das Gerät kann und
@@ -99,6 +128,120 @@ public static class StreamEndpoints
}
}).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.
app.MapGet("/api/v1/stream/{sitzung:guid}/index.m3u8", async (
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.
SessionId: Guid.Empty,
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",
// **Null, nicht `ab`.** Bei einer durchgereichten Datei springt
@@ -345,6 +493,7 @@ public static class StreamEndpoints
return new StreamResponse(
SessionId: sitzung.Kennung,
Url: $"/api/v1/stream/{sitzung.Kennung}/index.m3u8",
+ MasterUrl: $"/api/v1/stream/{sitzung.Kennung}/master.m3u8",
Method: sitzung.Art switch
{
Stromart.Direkt => "direct",
diff --git a/server/tests/Flaro.Api.Tests/StreamTests.cs b/server/tests/Flaro.Api.Tests/StreamTests.cs
index 6edc4e8..6f3e298 100644
--- a/server/tests/Flaro.Api.Tests/StreamTests.cs
+++ b/server/tests/Flaro.Api.Tests/StreamTests.cs
@@ -1,4 +1,5 @@
using System.Diagnostics;
+using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using Flaro.Api.Endpoints;
@@ -78,6 +79,51 @@ public class StreamTests
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();
@@ -374,4 +420,142 @@ public class StreamTests
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()
+ {
+ 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"));
+ });
+ }
+
+ /// 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();
+ 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.");
+ });
+ }
+
+ /// 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);
+ }
}