Einmal aus der Datei holen, nicht bei jedem Abruf
„Diese Untertitelspur liess sich nicht laden", und auf der zweiten Spur kam gar nichts. Nicht das Format war schuld und auch nicht das Verschieben, sondern die Zeit: jeder Abruf startete ffmpeg, ffmpeg musste die ganze Datei durchlesen, und nach dreissig Sekunden wurde abgebrochen. An einem Prüffilm von fünfzehn Megabyte fällt das nie auf; bei einem Dreistünder auf einem NAS reicht es nie. Schlimmer wurde es durch meine eigene Korrektur von gestern: seit die Adresse den Einstiegspunkt mitführt (`?ab=`), ändert sie sich bei jedem Sprung — der Browser konnte nichts mehr aus seinem Zwischenspeicher nehmen, und jeder Sprung stiess einen neuen Durchlauf an. Jetzt: - Ein ffmpeg-Durchlauf holt alle Textspuren auf einmal. Das Lesen der Datei ist der teure Teil und wird nicht mehr je Spur bezahlt. - Das Ergebnis bleibt liegen, ungeschoben; das Verschieben ist eine Zeilenersetzung und kostet nichts. Gemessen: erster Abruf 0,42 s, jeder weitere 2 bis 3 ms — und der zweite Sprung in derselben Datei trifft dieselbe Ablage statt eines neuen Durchlaufs. - Angestossen wird beim Start des Stroms, nicht erst beim Einschalten. Wer nach einer halben Stunde Untertitel will, wartet dann gar nicht. - Geschrieben wird unter `.teil` und erst danach umbenannt. Eine abgebrochene Datei am richtigen Namen gälte sonst für immer als fertig. - Die Geduld steigt von 30 Sekunden auf 30 Minuten. Sie ist jetzt eine Obergrenze gegen einen hängenden Prozess, keine Wartezeit mehr. Und der Spieler kann endlich unterscheiden, was los ist. Ein `<track>` meldet jeden Misserfolg gleich — „es gibt sie nicht", „sie enthält keinen Text" und „der Server holt sie gerade" sahen identisch aus. Geholt wird deshalb selbst, mit Token statt Keks (gemessen: ohne Keks kam jeder Abruf mit 401 zurück, ohne dass irgendwo etwas stand), und bei 503 fragt der Spieler nach, statt aufzugeben. Der Text geht als Blob an den `<track>` — dieselbe Anzeige, ein ehrlicher Weg dahin. Dazu eine Unsauberkeit im Verschieben: die Trennzeile eines weggefallenen Blocks blieb stehen. Gemessen am echten Film 52 Leerzeilen vor dem ersten Satz. Geprüft im Spieler, kalter Vorrat, Einstieg bei Sekunde 500: Blob am `<track>`, zehn Einblendungen, die erste bei 0 bis 3 s mit „Deutsch bei 500 s". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
339a6c07d0
commit
c2fd2a86fd
@@ -32,7 +32,8 @@ public static class StreamEndpoints
|
||||
// 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, TimeProvider uhr) =>
|
||||
Streamkoordinator koordinator, FlaroContext db, TimeProvider uhr,
|
||||
Untertitelvorrat vorrat) =>
|
||||
{
|
||||
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
|
||||
@@ -44,6 +45,17 @@ public static class StreamEndpoints
|
||||
if (await DeliveryEndpoints.FreiAb(db, gewuenschte, uhr.GetUtcNow()) is { } nochNicht)
|
||||
return DeliveryEndpoints.NochNicht(nochNicht);
|
||||
|
||||
// **Die Untertitel jetzt holen, nicht erst beim Einschalten.**
|
||||
//
|
||||
// Sie aus der Datei zu ziehen heisst, die Datei zu lesen — bei
|
||||
// einem Dreistünder auf einem NAS dauert das Minuten. Wer sie
|
||||
// nach einer halben Stunde Film einschaltet, soll dann nicht
|
||||
// warten; der Vorrat ist bis dahin längst gefüllt. Wer sie sofort
|
||||
// einschaltet, wartet so lange wie vorher, bekommt aber eine
|
||||
// Antwort statt eines Fehlers.
|
||||
vorrat.Anstossen(gewuenschte.Id, gewuenschte.Path, gewuenschte.ModifiedAt,
|
||||
await Textspuren(db, id, CancellationToken.None));
|
||||
|
||||
var profil = Profil(anfrage);
|
||||
var hoehe = anfrage.Height is { } gewuenscht
|
||||
&& Stromentscheid.Stufen.Contains(gewuenscht) ? gewuenscht : (int?)null;
|
||||
@@ -219,17 +231,38 @@ public static class StreamEndpoints
|
||||
// im Spieler: bei „Fortsetzen ab 8:37" stand der Satz von Sekunde 0
|
||||
// auf dem Bild.
|
||||
app.MapGet("/api/v1/files/{id:guid}/subtitles/{spur:int}.vtt", async (
|
||||
Guid id, int spur, FlaroContext db, UntertitelLeser leser,
|
||||
Guid id, int spur, FlaroContext db, Untertitelvorrat vorrat,
|
||||
CancellationToken ct, double ab = 0) =>
|
||||
{
|
||||
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id, ct);
|
||||
if (datei is null || !File.Exists(datei.Path)) return Results.NotFound();
|
||||
if (datei is null || !File.Exists(datei.Path))
|
||||
return Einwand.Fehlt("Diese Datei liegt nicht mehr da.");
|
||||
|
||||
var vtt = await leser.AlsWebVttAsync(datei.Path, spur, ct);
|
||||
if (vtt is null) return Results.NotFound();
|
||||
var spuren = await Textspuren(db, id, ct);
|
||||
if (!spuren.Contains(spur))
|
||||
return Einwand.Fehlt("Diese Spur enthält keinen Text, sondern Bilder.");
|
||||
|
||||
return Results.Text(
|
||||
Untertitelversatz.Verschieben(vtt, Math.Max(0, ab)), "text/vtt", Encoding.UTF8);
|
||||
var (stand, text) = await vorrat.HolenAsync(
|
||||
datei.Id, datei.Path, datei.ModifiedAt, spuren, spur,
|
||||
TimeSpan.FromSeconds(20), ct);
|
||||
|
||||
return stand switch
|
||||
{
|
||||
Untertitelvorrat.Stand.Fertig => Results.Text(
|
||||
Untertitelversatz.Verschieben(text, Math.Max(0, ab)), "text/vtt", Encoding.UTF8),
|
||||
|
||||
Untertitelvorrat.Stand.Leer =>
|
||||
Einwand.Fehlt("In dieser Spur steht kein Text."),
|
||||
|
||||
// **Kein Fehler, sondern ein Zwischenstand.** Der Spieler
|
||||
// fragt gleich noch einmal; ihm hier „nicht gefunden" zu
|
||||
// sagen hiesse, ihn auf etwas warten zu lassen, das er für
|
||||
// aussichtslos hält.
|
||||
_ => Results.Problem(
|
||||
"Die Untertitel werden gerade aus der Datei geholt. "
|
||||
+ "Bei einem grossen Film dauert das einen Moment.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable),
|
||||
};
|
||||
}).RequireAuthorization();
|
||||
}
|
||||
|
||||
@@ -342,6 +375,24 @@ public static class StreamEndpoints
|
||||
private static bool IstBild(string codec) =>
|
||||
codec is "hdmv_pgs_subtitle" or "pgssub" or "dvd_subtitle" or "dvdsub" or "xsub";
|
||||
|
||||
/// <summary>Die Spurnummern, aus denen sich Text holen lässt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Dieselbe Auswahl, die auch im Menü steht — sonst böte der Spieler eine
|
||||
/// Spur an, die der Vorrat gar nicht erst zieht.
|
||||
/// </remarks>
|
||||
private static async Task<IReadOnlyList<int>> Textspuren(
|
||||
FlaroContext db, Guid dateiId, CancellationToken ct)
|
||||
{
|
||||
var alle = await db.MediaStreams.AsNoTracking()
|
||||
.Where(s => s.MediaFileId == dateiId && s.Kind == MediaStreamKind.Subtitle)
|
||||
.OrderBy(s => s.Index)
|
||||
.Select(s => new { s.Index, s.Codec })
|
||||
.ToListAsync(ct);
|
||||
|
||||
return alle.Where(s => s.Codec is null || !IstBild(s.Codec)).Select(s => s.Index).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Wie eine Spur im Menü heisst.</summary>
|
||||
private static string Benennung(MediaStream spur)
|
||||
{
|
||||
|
||||
@@ -111,6 +111,7 @@ builder.Services.AddSingleton<PackageCoordinator>();
|
||||
// zum Mitnehmen; dieser lässt sie laufen, während sie entsteht.
|
||||
builder.Services.AddSingleton<Streamkoordinator>();
|
||||
builder.Services.AddSingleton<UntertitelLeser>();
|
||||
builder.Services.AddSingleton<Untertitelvorrat>();
|
||||
// Im Arbeitsspeicher und nur dort: wer gerade was schaut, ist eine Frage
|
||||
// ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand.
|
||||
builder.Services.AddSingleton<PlaybackSessions>();
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Flaro.Media;
|
||||
|
||||
namespace Flaro.Api;
|
||||
|
||||
/// <summary>Herausgezogene Untertitel, abgelegt für den zweiten Abruf.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Der Anlass.</b> Im Spieler stand „Diese Untertitelspur liess sich nicht
|
||||
/// laden", und auf der zweiten Spur kam überhaupt nichts. Der Grund lag nicht
|
||||
/// im Format, sondern in der Zeit: jeder Abruf startete ffmpeg, ffmpeg musste
|
||||
/// die ganze Datei durchlesen, und nach dreissig Sekunden wurde abgebrochen.
|
||||
/// Bei einem kleinen Prüffilm fällt das nie auf; bei einem Dreistünder auf
|
||||
/// einem NAS reicht es nie.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Und es wurde schlimmer, bevor es besser wurde.</b> Seit die Adresse den
|
||||
/// Einstiegspunkt mitführt (<c>?ab=</c>), ändert sie sich bei jedem Sprung —
|
||||
/// der Browser konnte nichts mehr aus seinem eigenen Zwischenspeicher
|
||||
/// nehmen, und jeder Sprung stiess einen neuen Durchlauf an. Deshalb liegt
|
||||
/// hier die ungeschobene Spur; das Verschieben ist eine Zeilenersetzung und
|
||||
/// kostet nichts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Warm, bevor jemand fragt.</b> Angestossen wird beim Start des Stroms,
|
||||
/// nicht erst beim Öffnen des Menüs. Wer nach fünf Minuten Untertitel
|
||||
/// einschaltet, wartet dann gar nicht mehr.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Untertitelvorrat(FlaroPaths pfade, UntertitelLeser leser)
|
||||
{
|
||||
/// <summary>Was ein Abruf vorfindet.</summary>
|
||||
public enum Stand
|
||||
{
|
||||
/// <summary>Liegt da, mit Text.</summary>
|
||||
Fertig,
|
||||
|
||||
/// <summary>Liegt da und ist leer — diese Spur hat keinen Text.</summary>
|
||||
Leer,
|
||||
|
||||
/// <summary>Wird gerade herausgezogen.</summary>
|
||||
Laeuft,
|
||||
}
|
||||
|
||||
/// <summary>Ein Lauf je Datei, nicht je Spur — er holt ohnehin alle.</summary>
|
||||
private readonly ConcurrentDictionary<Guid, Task> _laeuft = new();
|
||||
|
||||
public string Pfad(Guid dateiId, DateTimeOffset geaendert, int spur) =>
|
||||
Path.Combine(pfade.Transcode, "untertitel",
|
||||
$"{dateiId:N}-{geaendert.ToUnixTimeMilliseconds()}-{spur}.vtt");
|
||||
|
||||
/// <summary>Stösst das Herausziehen an, wenn etwas fehlt — und kehrt sofort zurück.</summary>
|
||||
public void Anstossen(Guid dateiId, string quelle, DateTimeOffset geaendert, IReadOnlyList<int> spuren)
|
||||
{
|
||||
if (spuren.Count == 0) return;
|
||||
|
||||
var offen = spuren
|
||||
.Where(s => !File.Exists(Pfad(dateiId, geaendert, s)))
|
||||
.Select(s => new UntertitelLeser.Auftrag(s, Pfad(dateiId, geaendert, s)))
|
||||
.ToList();
|
||||
|
||||
if (offen.Count == 0) return;
|
||||
|
||||
// **Ohne das Abbruchzeichen des Abrufers.** Der Lauf gehört nicht dem
|
||||
// ersten, der gefragt hat: schaltet der sein Fenster zu, während die
|
||||
// Datei gelesen wird, wäre sonst alles umsonst und der nächste finge
|
||||
// von vorn an.
|
||||
_laeuft.GetOrAdd(dateiId, _ => Task.Run(async () =>
|
||||
{
|
||||
try { await leser.ZiehenAsync(quelle, offen, CancellationToken.None); }
|
||||
finally { _laeuft.TryRemove(dateiId, out Task? _); }
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>Holt eine Spur — und wartet dabei höchstens <paramref name="geduld"/>.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Warum überhaupt gewartet wird.</b> Ein Abruf, der sofort „noch
|
||||
/// nicht" sagt, zwingt den Spieler in eine Schleife, auch wenn die Datei
|
||||
/// in zwei Sekunden fertig gewesen wäre. Ein Abruf, der unbegrenzt
|
||||
/// wartet, hängt hinter jedem Reverse Proxy ins Zeitlimit. Zwanzig
|
||||
/// Sekunden decken den gewöhnlichen Fall ab; der seltene bekommt eine
|
||||
/// Antwort, mit der sich etwas anfangen lässt.
|
||||
/// </remarks>
|
||||
public async Task<(Stand Stand, string Text)> HolenAsync(
|
||||
Guid dateiId, string quelle, DateTimeOffset geaendert,
|
||||
IReadOnlyList<int> spuren, int spur, TimeSpan geduld, CancellationToken ct)
|
||||
{
|
||||
var ziel = Pfad(dateiId, geaendert, spur);
|
||||
|
||||
Anstossen(dateiId, quelle, geaendert, spuren);
|
||||
|
||||
var bis = DateTimeOffset.UtcNow + geduld;
|
||||
while (true)
|
||||
{
|
||||
if (File.Exists(ziel))
|
||||
{
|
||||
var text = await File.ReadAllTextAsync(ziel, ct);
|
||||
return (text.Length == 0 ? Stand.Leer : Stand.Fertig, text);
|
||||
}
|
||||
|
||||
// Der Lauf ist durch und die Datei fehlt trotzdem: diese Spur
|
||||
// liess sich nicht herausziehen. Weiterzuwarten hiesse, auf
|
||||
// etwas zu warten, das niemand mehr tut.
|
||||
if (!_laeuft.ContainsKey(dateiId)) return (Stand.Leer, "");
|
||||
|
||||
if (DateTimeOffset.UtcNow >= bis) return (Stand.Laeuft, "");
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(400), ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user