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:
Scarriffle
2026-10-10 14:17:47 +02:00
co-authored by Claude Opus 5
parent 912969d829
commit 4a5576f939
3 changed files with 345 additions and 0 deletions
@@ -770,6 +770,18 @@ public sealed record SpurAuskunft(int Index, string Label, string? Language, boo
public sealed record StreamResponse(
Guid SessionId,
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,
double StartSeconds,
int? Height,
@@ -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;
/// </remarks>
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)
{
// **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",