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); + } }