Er packte um, was gar nicht am Umpacken lag
Der Browser bekam stillschweigend Apples Geräteprofil — und mit ihm HEVC, AC3 und EAC3, von denen er keines öffnet. Der Server packte die Datei pflichtschuldig nach MP4 um, liess Bild und Ton dabei unberührt, und heraus kam eine tadellose Datei, mit der Chrome nichts anfangen konnte. Umpacken fasst das Bild nun einmal nicht an. Jetzt sagt das Gerät selbst, was es kann, und es gibt einen zweiten Weg neben der fertigen Datei: HLS, das nach Sekunden anfängt statt nach Minuten. Dasselbe Rohr erledigt beides — mkv nach fMP4 mit „-c:v copy", 4K nach 480p mit libx264 — und eine MP4, die der Browser ohnehin öffnet, geht ganz ohne ffmpeg hinaus. Dazu eine eigene Steuerleiste: Tonspur und Untertitel während der Wiedergabe umschaltbar, vier Auflösungsstufen, Tastenkürzel, und bei einem Fehler ein deutscher Satz statt eines schwarzen Bildes. Gemessen statt vermutet: Chromium antwortet auf canPlayType für HLS mit "maybe" und kann es dann doch nicht — deshalb entscheidet Hls.isSupported(). hls.js wiegt 372 KB und wird erst beim Abspielen geholt; die Oberfläche bleibt bei 109. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
557708b769
commit
4853200d5c
@@ -430,6 +430,47 @@ public sealed record BulkWatchedResponse(int Changed, IReadOnlyList<Guid> Unknow
|
||||
|
||||
/// <summary>Welche Titel neu geholt werden sollen — leer heisst: alle.</summary>
|
||||
public sealed record RefreshRequest(IReadOnlyList<Guid>? Ids);
|
||||
|
||||
/// <summary>Was ein Spieler kann und was er sehen will.</summary>
|
||||
///
|
||||
/// <param name="Containers">Schachteln, die das Gerät öffnet — „mp4", „webm".</param>
|
||||
/// <param name="Height">
|
||||
/// Die gewünschte Stufe: 1080, 720 oder 480. <c>null</c> heisst Originalauflösung.
|
||||
/// </param>
|
||||
/// <param name="AudioStream">
|
||||
/// Welche Tonspur — die Nummer aus der Datei. <c>null</c> nimmt die erste.
|
||||
/// </param>
|
||||
/// <param name="StartSeconds">Ab welcher Sekunde des Films.</param>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Das Gerät sagt, was es kann, statt dass der Server es rät.</b> Vorher
|
||||
/// bekam jeder Browser Apples Profil — und mit ihm HEVC, AC3 und EAC3, von
|
||||
/// denen er keines öffnen kann.
|
||||
/// </remarks>
|
||||
public sealed record StreamRequest(
|
||||
IReadOnlyList<string>? Containers,
|
||||
IReadOnlyList<string>? VideoCodecs,
|
||||
IReadOnlyList<string>? AudioCodecs,
|
||||
int? Height = null,
|
||||
int? AudioStream = null,
|
||||
double? StartSeconds = null);
|
||||
|
||||
/// <summary>Eine wählbare Spur, so wie sie im Menü steht.</summary>
|
||||
public sealed record SpurAuskunft(int Index, string Label, string? Language, bool IsDefault);
|
||||
|
||||
/// <param name="StartSeconds">
|
||||
/// Ab welcher Sekunde des Films dieser Strom beginnt. <b>Der Spieler zählt ab
|
||||
/// null</b> — ohne diesen Wert ginge jeder gemeldete Stand um den Vorlauf daneben.
|
||||
/// </param>
|
||||
public sealed record StreamResponse(
|
||||
Guid SessionId,
|
||||
string Url,
|
||||
string Method,
|
||||
double StartSeconds,
|
||||
int? Height,
|
||||
int? AudioStream,
|
||||
IReadOnlyList<SpurAuskunft> AudioStreams,
|
||||
IReadOnlyList<SpurAuskunft> SubtitleStreams);
|
||||
public sealed record RatingRequest(int? Rating);
|
||||
|
||||
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Flaro.Core.Delivery;
|
||||
using Flaro.Core.Library;
|
||||
using Flaro.Data;
|
||||
using Flaro.Media;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api.Endpoints;
|
||||
|
||||
/// <summary>Wiedergabe, die sofort anfängt — statt auf eine fertige Datei zu warten.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Warum es diesen zweiten Weg gibt.</b> <see cref="DeliveryEndpoints"/>
|
||||
/// baut eine fertige MP4 und antwortet solange mit 202. Für eine App, die den
|
||||
/// Film mitnehmen will, ist das richtig. Im Browser heisst es: Minuten vor dem
|
||||
/// ersten Bild — und bei einem Bildcodec, den der Browser nicht kennt, ein
|
||||
/// Umpacken, das nichts nützt, weil es das Bild gar nicht anfasst.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Der Browser sagt, was er kann.</b> Vorher bekam er stillschweigend
|
||||
/// Apples Geräteprofil, das HEVC, AC3 und EAC3 erlaubt — und stand dann vor
|
||||
/// einer Datei, von der er nichts öffnen konnte.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class StreamEndpoints
|
||||
{
|
||||
public static void MapStream(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// **Eine Sitzung anlegen.** Der Rumpf trägt, was das Gerät kann und
|
||||
// was der Benutzer gewählt hat.
|
||||
app.MapPost("/api/v1/files/{id:guid}/stream", async (
|
||||
Guid id, StreamRequest anfrage, ClaimsPrincipal wer,
|
||||
Streamkoordinator koordinator, FlaroContext db) =>
|
||||
{
|
||||
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
|
||||
var profil = Profil(anfrage);
|
||||
var hoehe = anfrage.Height is { } gewuenscht
|
||||
&& Stromentscheid.Stufen.Contains(gewuenscht) ? gewuenscht : (int?)null;
|
||||
|
||||
try
|
||||
{
|
||||
// **Was der Browser ohnehin öffnet, geht unverändert hinaus.**
|
||||
//
|
||||
// Eine MP4 mit H.264 und AAC durch ffmpeg zu schicken wäre ein
|
||||
// Prozess und ein Kratzplatzordner für nichts — und der
|
||||
// Browser verlöre dabei das, was er von sich aus am besten
|
||||
// kann: springen über Bereichsanfragen, ohne dass irgendwo
|
||||
// etwas neu gerechnet wird.
|
||||
//
|
||||
// **Nur ohne Wünsche.** Wer eine Tonspur oder eine Stufe
|
||||
// wählt, bekommt eine Sitzung: beides lässt sich an einer
|
||||
// durchgereichten Datei nicht einstellen.
|
||||
if (hoehe is null && anfrage.AudioStream is null
|
||||
&& await Durchreichbar(db, id, profil) is { } datei)
|
||||
{
|
||||
return Results.Ok(await Direktauskunft(db, datei, anfrage.StartSeconds ?? 0));
|
||||
}
|
||||
|
||||
var sitzung = await koordinator.StartenAsync(
|
||||
id, benutzer, profil, hoehe, anfrage.AudioStream, anfrage.StartSeconds ?? 0);
|
||||
|
||||
return Results.Ok(await Auskunft(db, sitzung));
|
||||
}
|
||||
catch (InvalidOperationException fehler)
|
||||
{
|
||||
// Ein deutscher Satz, kein Fehlercode — hier wird gelesen,
|
||||
// nicht gesucht. Die Einzelheiten stehen im Protokoll.
|
||||
return Results.Problem(fehler.Message, statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
}).RequireAuthorization();
|
||||
|
||||
// **Die Playlist.** Sie wächst, während ffmpeg schreibt.
|
||||
app.MapGet("/api/v1/stream/{sitzung:guid}/index.m3u8", async (
|
||||
Guid sitzung, Streamkoordinator koordinator, CancellationToken ct) =>
|
||||
{
|
||||
if (koordinator.Sitzung(sitzung) is not { } laufend) return Results.NotFound();
|
||||
koordinator.Lebenszeichen(sitzung);
|
||||
|
||||
// **Hier wird gewartet, nicht abgewiesen.**
|
||||
//
|
||||
// Zuerst stand hier eine 204, solange noch kein Segment da war —
|
||||
// der Spieler sollte gleich noch einmal fragen. Gemessen: hls.js
|
||||
// hält jede Antwort ohne Playlist für einen Fehler und gibt sofort
|
||||
// auf. Eine Antwort, die ein paar Sekunden braucht, ist für ihn
|
||||
// dagegen das Normale.
|
||||
//
|
||||
// Gemessen am Prüfbestand: beim Umpacken liegt das erste Segment
|
||||
// nach deutlich unter einer Sekunde vor. Die Spanne hier ist für
|
||||
// den anderen Fall gedacht — ein 4K-Film, der neu gerechnet wird.
|
||||
for (var versuch = 0; versuch < 60; versuch++)
|
||||
{
|
||||
if (ct.IsCancellationRequested) return Results.NoContent();
|
||||
|
||||
if (koordinator.Sitzung(sitzung) is not { } jetzt) return Results.NotFound();
|
||||
|
||||
if (jetzt.Fehler is { Length: > 0 })
|
||||
{
|
||||
return Results.Problem(
|
||||
"Die Wiedergabe liess sich nicht aufbereiten. "
|
||||
+ "Die Einzelheiten stehen im Protokoll von heute.",
|
||||
statusCode: StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
|
||||
if (Streamkoordinator.PlaylistBereit(jetzt.Ordner))
|
||||
return Results.File(Path.Combine(jetzt.Ordner, HlsArgumente.Playlist),
|
||||
"application/vnd.apple.mpegurl");
|
||||
|
||||
// **Hört ffmpeg auf, ohne ein Segment geschrieben zu haben**,
|
||||
// kommt auch keines mehr. Weiterzuwarten hiesse, eine halbe
|
||||
// Minute auf etwas zu warten, das es nicht geben wird.
|
||||
if (jetzt.Beendet)
|
||||
{
|
||||
return Results.Problem(
|
||||
"Für diese Datei kam keine abspielbare Fassung zustande. "
|
||||
+ "Die Einzelheiten stehen im Protokoll von heute.",
|
||||
statusCode: StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
|
||||
await Task.Delay(250, ct);
|
||||
}
|
||||
|
||||
return Results.Problem(
|
||||
"Die Aufbereitung dauert ungewöhnlich lange. Versuche eine kleinere Auflösung.",
|
||||
statusCode: StatusCodes.Status504GatewayTimeout);
|
||||
}).RequireAuthorization();
|
||||
|
||||
// **Die Segmente.** Jedes Abholen ist zugleich das Lebenszeichen.
|
||||
app.MapGet("/api/v1/stream/{sitzung:guid}/{datei}", (
|
||||
Guid sitzung, string datei, Streamkoordinator koordinator) =>
|
||||
{
|
||||
if (koordinator.Sitzung(sitzung) is not { } laufend) return Results.NotFound();
|
||||
|
||||
// **Nur der blosse Name.** Ohne diese Prüfung käme man mit
|
||||
// `../../config/flaro.db` an die Datenbank — ein Pfad aus der
|
||||
// Anfrage darf nie ungeprüft an das Dateisystem.
|
||||
if (datei != Path.GetFileName(datei) || datei.Contains("..", StringComparison.Ordinal))
|
||||
return Results.BadRequest();
|
||||
|
||||
if (!datei.EndsWith(".m4s", StringComparison.Ordinal)
|
||||
&& !datei.EndsWith(".mp4", StringComparison.Ordinal))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
koordinator.Lebenszeichen(sitzung);
|
||||
|
||||
var pfad = Path.Combine(laufend.Ordner, datei);
|
||||
return File.Exists(pfad)
|
||||
? Results.File(pfad, "video/mp4", enableRangeProcessing: true)
|
||||
: Results.NotFound();
|
||||
}).RequireAuthorization();
|
||||
|
||||
// **Schliessen heisst aufräumen.** Ohne das liefe ffmpeg weiter, bis
|
||||
// die Geduld der Sitzung abgelaufen ist — und schriebe solange den
|
||||
// ganzen Film auf den Kratzplatz.
|
||||
app.MapDelete("/api/v1/stream/{sitzung:guid}", (Guid sitzung, Streamkoordinator koordinator) =>
|
||||
{
|
||||
koordinator.Beenden(sitzung);
|
||||
return Results.NoContent();
|
||||
}).RequireAuthorization();
|
||||
|
||||
// **Dasselbe noch einmal als POST, für `sendBeacon`.**
|
||||
//
|
||||
// Beim Schliessen eines Reiters kommt eine gewöhnliche Anfrage oft
|
||||
// nicht mehr durch — der Browser bricht sie mit der Seite ab.
|
||||
// `sendBeacon` überlebt das, kann aber nur POST. Ohne diesen Weg
|
||||
// bliebe nach jedem geschlossenen Reiter ein ffmpeg stehen, bis die
|
||||
// Geduld der Sitzung abgelaufen ist.
|
||||
app.MapPost("/api/v1/stream/{sitzung:guid}/schliessen", (
|
||||
Guid sitzung, Streamkoordinator koordinator) =>
|
||||
{
|
||||
koordinator.Beenden(sitzung);
|
||||
return Results.NoContent();
|
||||
}).RequireAuthorization();
|
||||
|
||||
// **Untertitel als eigene Datei, nicht im Strom.**
|
||||
//
|
||||
// So lassen sie sich während der Wiedergabe umschalten, ohne dass
|
||||
// irgendetwas neu gerechnet wird. In einer fMP4-Segmentierung hätten
|
||||
// sie ohnehin keinen Platz.
|
||||
app.MapGet("/api/v1/files/{id:guid}/subtitles/{spur:int}.vtt", async (
|
||||
Guid id, int spur, FlaroContext db, UntertitelLeser leser, CancellationToken ct) =>
|
||||
{
|
||||
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id, ct);
|
||||
if (datei is null || !File.Exists(datei.Path)) return Results.NotFound();
|
||||
|
||||
var vtt = await leser.AlsWebVttAsync(datei.Path, spur, ct);
|
||||
return vtt is null
|
||||
? Results.NotFound()
|
||||
: Results.Text(vtt, "text/vtt", Encoding.UTF8);
|
||||
}).RequireAuthorization();
|
||||
}
|
||||
|
||||
/// <summary>Die Datei, wenn der Browser sie unverändert öffnen kann — sonst <c>null</c>.</summary>
|
||||
private static async Task<Guid?> Durchreichbar(FlaroContext db, Guid id, DeviceProfile profil)
|
||||
{
|
||||
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id);
|
||||
if (datei is null || !File.Exists(datei.Path)) return null;
|
||||
|
||||
var spuren = await db.MediaStreams.AsNoTracking()
|
||||
.Where(s => s.MediaFileId == id).OrderBy(s => s.Index).ToListAsync();
|
||||
|
||||
var gelesen = spuren.Select(s => new ProbedStream(
|
||||
s.Index, s.Kind, s.Codec, s.Language, s.Title,
|
||||
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height)).ToList();
|
||||
|
||||
return Stromentscheid.Fuer(datei.Container, gelesen, profil).Art == Stromart.Direkt
|
||||
? datei.Id
|
||||
: null;
|
||||
}
|
||||
|
||||
private static async Task<StreamResponse> Direktauskunft(FlaroContext db, Guid datei, double ab)
|
||||
{
|
||||
var spuren = await db.MediaStreams.AsNoTracking()
|
||||
.Where(s => s.MediaFileId == datei)
|
||||
.OrderBy(s => s.Index)
|
||||
.ToListAsync();
|
||||
|
||||
return new StreamResponse(
|
||||
// **Keine Sitzung, also keine Kennung.** Der Spieler merkt daran,
|
||||
// dass es nichts zu beenden und nichts aufzuräumen gibt.
|
||||
SessionId: Guid.Empty,
|
||||
Url: $"/api/v1/files/{datei}/download",
|
||||
Method: "direct",
|
||||
|
||||
// **Null, nicht `ab`.** Bei einer durchgereichten Datei springt
|
||||
// der Spieler selbst; es gibt keinen Vorlauf, den er aufrechnen
|
||||
// müsste. Ihn hier zu melden hiesse, die Stelle doppelt zu zählen.
|
||||
StartSeconds: 0,
|
||||
Height: null,
|
||||
AudioStream: spuren.FirstOrDefault(s => s.Kind == MediaStreamKind.Audio)?.Index,
|
||||
AudioStreams: spuren.Where(s => s.Kind == MediaStreamKind.Audio)
|
||||
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
|
||||
.ToList(),
|
||||
SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle)
|
||||
.Where(s => s.Codec is null || !IstBild(s.Codec))
|
||||
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
/// <summary>Das Geräteprofil aus der Anfrage — oder, wenn keines kam, Apples.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die Rückfallebene ist nicht harmlos.</b> Sie erlaubt HEVC und EAC3;
|
||||
/// ein Browser, der sein Profil nicht mitschickt, bekommt damit womöglich
|
||||
/// etwas, das er nicht abspielen kann. Alte Clients sollen aber nicht
|
||||
/// plötzlich gar nichts mehr bekommen — die Apps schicken ohnehin bald
|
||||
/// ihr eigenes.
|
||||
/// </remarks>
|
||||
private static DeviceProfile Profil(StreamRequest anfrage)
|
||||
{
|
||||
if (anfrage.Containers is not { Count: > 0 }
|
||||
|| anfrage.VideoCodecs is not { Count: > 0 }
|
||||
|| anfrage.AudioCodecs is not { Count: > 0 })
|
||||
{
|
||||
return DeviceProfile.AppleDefault;
|
||||
}
|
||||
|
||||
return new DeviceProfile(
|
||||
new HashSet<string>(anfrage.Containers, StringComparer.OrdinalIgnoreCase),
|
||||
new HashSet<string>(anfrage.VideoCodecs, StringComparer.OrdinalIgnoreCase),
|
||||
new HashSet<string>(anfrage.AudioCodecs, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static async Task<StreamResponse> Auskunft(FlaroContext db, Stromsitzung sitzung)
|
||||
{
|
||||
var spuren = await db.MediaStreams.AsNoTracking()
|
||||
.Where(s => s.MediaFileId == sitzung.DateiId)
|
||||
.OrderBy(s => s.Index)
|
||||
.ToListAsync();
|
||||
|
||||
return new StreamResponse(
|
||||
SessionId: sitzung.Kennung,
|
||||
Url: $"/api/v1/stream/{sitzung.Kennung}/index.m3u8",
|
||||
Method: sitzung.Art switch
|
||||
{
|
||||
Stromart.Direkt => "direct",
|
||||
Stromart.Umpacken => "remux",
|
||||
_ => "transcode",
|
||||
},
|
||||
StartSeconds: sitzung.Ab,
|
||||
Height: sitzung.Hoehe,
|
||||
AudioStream: sitzung.Tonspur,
|
||||
AudioStreams: spuren.Where(s => s.Kind == MediaStreamKind.Audio)
|
||||
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
|
||||
.ToList(),
|
||||
SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle)
|
||||
.Where(s => s.Codec is null || !IstBild(s.Codec))
|
||||
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
/// <summary>Bildbasierte Untertitel lassen sich nicht nach WebVTT wandeln.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Sie anzubieten wäre eine Zusage, die nicht eingelöst werden kann.</b>
|
||||
/// PGS ist auf jedem Blu-ray-Rip die Regel — ein Eintrag im Menü, der zu
|
||||
/// nichts führt, ist schlechter als kein Eintrag.
|
||||
/// </remarks>
|
||||
private static bool IstBild(string codec) =>
|
||||
codec is "hdmv_pgs_subtitle" or "pgssub" or "dvd_subtitle" or "dvdsub" or "xsub";
|
||||
|
||||
/// <summary>Wie eine Spur im Menü heisst.</summary>
|
||||
private static string Benennung(MediaStream spur)
|
||||
{
|
||||
if (spur.Title is { Length: > 0 } eigen) return eigen;
|
||||
|
||||
var sprache = Sprache(spur.Language);
|
||||
var teile = new List<string> { sprache };
|
||||
if (spur.Channels is { } k and > 2) teile.Add($"{k} Kanäle");
|
||||
if (spur.IsForced) teile.Add("erzwungen");
|
||||
return string.Join(" · ", teile);
|
||||
}
|
||||
|
||||
/// <summary>Dreibuchstabige Kennungen in Worte — für die üblichen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Keine vollständige Tabelle.</b> „deu" steht auf fast jeder Datei
|
||||
/// hier; die übrigen bleiben, wie sie sind, statt eine Liste zu pflegen,
|
||||
/// die nie fertig wird.
|
||||
/// </remarks>
|
||||
private static string Sprache(string? kennung) => kennung?.ToLowerInvariant() switch
|
||||
{
|
||||
null or "" or "und" => "Unbenannt",
|
||||
"deu" or "ger" or "de" => "Deutsch",
|
||||
"eng" or "en" => "Englisch",
|
||||
"fra" or "fre" or "fr" => "Französisch",
|
||||
"ita" or "it" => "Italienisch",
|
||||
"spa" or "es" => "Spanisch",
|
||||
"jpn" or "ja" => "Japanisch",
|
||||
_ => kennung!.ToUpperInvariant(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user