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
@@ -3,7 +3,7 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Flaro.Media;
|
||||
|
||||
/// <summary>Holt eine Untertitelspur als WebVTT aus einer Datei.</summary>
|
||||
/// <summary>Holt Untertitelspuren als WebVTT aus einer Datei.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -18,24 +18,55 @@ namespace Flaro.Media;
|
||||
/// zu machen hiesse, Schrift zu erkennen. Sie werden gar nicht erst
|
||||
/// angeboten — siehe <c>StreamEndpoints</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Alle Spuren in einem Durchlauf, und warum das der ganze Unterschied
|
||||
/// ist.</b> Untertitelpakete liegen über die ganze Datei verteilt; ffmpeg
|
||||
/// muss sie bis zum Ende lesen, auch wenn am Ende nur vierzig Kilobyte
|
||||
/// herauskommen. Bei einem Dreistünder auf einem NAS sind das Minuten — je
|
||||
/// Spur. Vorher lief dieser Durchlauf bei jedem Abruf neu und brach nach
|
||||
/// dreissig Sekunden ab; gemeldet wurde das als „Diese Untertitelspur liess
|
||||
/// sich nicht laden". Jetzt holt ein Durchlauf alle Spuren auf einmal, und
|
||||
/// der <c>Untertitelvorrat</c> sorgt dafür, dass es bei diesem einen bleibt.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class UntertitelLeser(FfmpegOptions optionen, ILogger<UntertitelLeser> log)
|
||||
{
|
||||
/// <summary>Wie lange das Herausziehen höchstens dauern darf.</summary>
|
||||
/// <summary>Wie lange ein Durchlauf höchstens dauern darf.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Untertitel sind ein paar Dutzend Kilobyte; braucht es dafür länger als
|
||||
/// eine halbe Minute, stimmt etwas mit der Datei nicht — und ein Spieler,
|
||||
/// der ewig auf einen Text wartet, ist schlimmer als einer ohne.
|
||||
/// <b>Grosszügig, weil es einmal geschieht.</b> Vorher standen hier
|
||||
/// dreissig Sekunden, mit der Begründung, Untertitel seien ein paar
|
||||
/// Dutzend Kilobyte. Das stimmt für das Ergebnis und nicht für den Weg
|
||||
/// dahin: gelesen wird die ganze Datei. Eine halbe Stunde ist keine
|
||||
/// Geduld, sondern eine Obergrenze gegen einen hängenden Prozess — der
|
||||
/// Vorrat fragt nicht so lange, er legt nur ab, was zurückkommt.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan Geduld = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan Geduld = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <returns><c>null</c>, wenn es die Spur nicht gibt oder sie kein Text ist.</returns>
|
||||
public async Task<string?> AlsWebVttAsync(string datei, int spur, CancellationToken ct = default)
|
||||
/// <summary>Eine Spur und die Datei, in die sie soll.</summary>
|
||||
public readonly record struct Auftrag(int Spur, string Ziel);
|
||||
|
||||
/// <summary>Zieht die genannten Spuren heraus und legt sie ab.</summary>
|
||||
///
|
||||
/// <returns>
|
||||
/// Die Spuren, für die wirklich Text herauskam. Eine Spur, die leer
|
||||
/// bleibt, fehlt hier — ffmpeg gibt auch für eine Bildspur eine gültige,
|
||||
/// aber inhaltslose WebVTT-Datei aus, und die auszuliefern hiesse, leere
|
||||
/// Untertitel anzubieten.
|
||||
/// </returns>
|
||||
public async Task<IReadOnlyList<int>> ZiehenAsync(
|
||||
string datei, IReadOnlyList<Auftrag> auftraege, CancellationToken ct = default)
|
||||
{
|
||||
if (auftraege.Count == 0) return [];
|
||||
|
||||
using var zeitlimit = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
zeitlimit.CancelAfter(Geduld);
|
||||
|
||||
foreach (var auftrag in auftraege)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(auftrag.Ziel)!);
|
||||
}
|
||||
|
||||
var start = new ProcessStartInfo(optionen.Ffmpeg)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
@@ -43,19 +74,27 @@ public sealed class UntertitelLeser(FfmpegOptions optionen, ILogger<UntertitelLe
|
||||
UseShellExecute = false,
|
||||
};
|
||||
|
||||
foreach (var a in new[]
|
||||
{
|
||||
"-loglevel", "error",
|
||||
"-i", datei,
|
||||
"-map", $"0:{spur}",
|
||||
"-c:s", "webvtt",
|
||||
"-f", "webvtt",
|
||||
// Auf die Standardausgabe statt in eine Datei: es geht um
|
||||
// Kilobyte, und eine Datei müsste wieder weggeräumt werden.
|
||||
"pipe:1",
|
||||
})
|
||||
// **Ein Eingang, mehrere Ausgänge.** ffmpeg liest die Datei dabei
|
||||
// genau einmal — das ist der teure Teil, und er wird nicht je Spur
|
||||
// bezahlt.
|
||||
start.ArgumentList.Add("-loglevel");
|
||||
start.ArgumentList.Add("error");
|
||||
start.ArgumentList.Add("-y");
|
||||
start.ArgumentList.Add("-i");
|
||||
start.ArgumentList.Add(datei);
|
||||
|
||||
foreach (var auftrag in auftraege)
|
||||
{
|
||||
start.ArgumentList.Add(a);
|
||||
foreach (var a in new[]
|
||||
{
|
||||
"-map", $"0:{auftrag.Spur}",
|
||||
"-c:s", "webvtt",
|
||||
"-f", "webvtt",
|
||||
auftrag.Ziel + ".teil",
|
||||
})
|
||||
{
|
||||
start.ArgumentList.Add(a);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
@@ -63,7 +102,6 @@ public sealed class UntertitelLeser(FfmpegOptions optionen, ILogger<UntertitelLe
|
||||
using var prozess = Process.Start(start)
|
||||
?? throw new InvalidOperationException($"{optionen.Ffmpeg} liess sich nicht starten.");
|
||||
|
||||
var lesen = prozess.StandardOutput.ReadToEndAsync(zeitlimit.Token);
|
||||
var fehlerLesen = prozess.StandardError.ReadToEndAsync(zeitlimit.Token);
|
||||
|
||||
try
|
||||
@@ -76,24 +114,65 @@ public sealed class UntertitelLeser(FfmpegOptions optionen, ILogger<UntertitelLe
|
||||
throw;
|
||||
}
|
||||
|
||||
var text = await lesen;
|
||||
if (prozess.ExitCode != 0)
|
||||
{
|
||||
log.LogDebug("Untertitelspur {Spur} aus {Datei}: {Fehler}",
|
||||
spur, Path.GetFileName(datei), (await fehlerLesen).Trim());
|
||||
return null;
|
||||
log.LogDebug("Untertitel aus {Datei}: {Fehler}",
|
||||
Path.GetFileName(datei), (await fehlerLesen).Trim());
|
||||
}
|
||||
|
||||
// **Eine leere Antwort ist keine Antwort.** ffmpeg gibt bei einer
|
||||
// Bildspur eine gültige, aber inhaltslose WebVTT-Datei aus; sie
|
||||
// auszuliefern hiesse, leere Untertitel anzubieten.
|
||||
return text.Contains("-->", StringComparison.Ordinal) ? text : null;
|
||||
return Abnehmen(auftraege);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
log.LogWarning("Untertitelspur {Spur} aus {Datei} brauchte länger als {Geduld}.",
|
||||
spur, Path.GetFileName(datei), Geduld);
|
||||
return null;
|
||||
log.LogWarning("Untertitel aus {Datei} brauchten länger als {Geduld}.",
|
||||
Path.GetFileName(datei), Geduld);
|
||||
Wegraeumen(auftraege);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Nimmt ab, was brauchbar ist, und rückt es an seinen Platz.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Erst unter <c>.teil</c>, dann umbenennen.</b> Bricht der Lauf
|
||||
/// mittendrin ab, liegt sonst eine halbe Spur am richtigen Namen und
|
||||
/// gilt für immer als fertig. Umbenennen ist auf demselben Dateisystem
|
||||
/// ein Zug und kann nicht halb geschehen.
|
||||
/// </remarks>
|
||||
private static IReadOnlyList<int> Abnehmen(IReadOnlyList<Auftrag> auftraege)
|
||||
{
|
||||
var fertig = new List<int>();
|
||||
|
||||
foreach (var auftrag in auftraege)
|
||||
{
|
||||
var teil = auftrag.Ziel + ".teil";
|
||||
if (!File.Exists(teil)) continue;
|
||||
|
||||
var text = File.ReadAllText(teil);
|
||||
File.Delete(teil);
|
||||
|
||||
// Eine Spur ohne eine einzige Einblendung ist keine Spur. Die
|
||||
// leere Datei bleibt trotzdem liegen: sie ist die Antwort
|
||||
// „hier ist nichts", und ohne sie würde bei jedem Abruf neu
|
||||
// die ganze Datei gelesen.
|
||||
if (!text.Contains("-->", StringComparison.Ordinal))
|
||||
{
|
||||
File.WriteAllText(auftrag.Ziel, "");
|
||||
continue;
|
||||
}
|
||||
|
||||
File.WriteAllText(auftrag.Ziel, text);
|
||||
fertig.Add(auftrag.Spur);
|
||||
}
|
||||
|
||||
return fertig;
|
||||
}
|
||||
|
||||
private static void Wegraeumen(IReadOnlyList<Auftrag> auftraege)
|
||||
{
|
||||
foreach (var auftrag in auftraege)
|
||||
{
|
||||
try { File.Delete(auftrag.Ziel + ".teil"); } catch (IOException) { /* Kür */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,16 @@ public static partial class Untertitelversatz
|
||||
|
||||
if (zeile.Trim().Length == 0)
|
||||
{
|
||||
if (behalten) foreach (var b in block) heraus.Append(b).Append('\n');
|
||||
heraus.Append('\n');
|
||||
// **Die Trennzeile gehört dem Block, nicht der Datei.** Fällt
|
||||
// der Block weg, fällt sie mit — sonst stünden am Anfang
|
||||
// eines mittendrin begonnenen Films so viele Leerzeilen, wie
|
||||
// Sätze davor lagen. Gemessen: 52 Stück vor dem ersten Satz.
|
||||
if (behalten && block.Count > 0)
|
||||
{
|
||||
foreach (var b in block) heraus.Append(b).Append('\n');
|
||||
heraus.Append('\n');
|
||||
}
|
||||
|
||||
block.Clear();
|
||||
behalten = true;
|
||||
continue;
|
||||
@@ -82,7 +90,7 @@ public static partial class Untertitelversatz
|
||||
block.Add(zeile);
|
||||
}
|
||||
|
||||
if (behalten) foreach (var b in block) heraus.Append(b).Append('\n');
|
||||
if (behalten && block.Count > 0) foreach (var b in block) heraus.Append(b).Append('\n');
|
||||
return heraus.ToString();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user