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:
Scarriffle
2026-10-07 22:00:06 +02:00
co-authored by Claude Opus 5
parent 339a6c07d0
commit c2fd2a86fd
13 changed files with 514 additions and 54 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +1,2 @@
21:35:00 INFO Server gestartet — 1.0.0 · DEV 21:35:00 INFO Server gestartet — 1.0.0 · DEV
21:55:29 INFO Server fährt herunter.
@@ -32,7 +32,8 @@ public static class StreamEndpoints
// was der Benutzer gewählt hat. // was der Benutzer gewählt hat.
app.MapPost("/api/v1/files/{id:guid}/stream", async ( app.MapPost("/api/v1/files/{id:guid}/stream", async (
Guid id, StreamRequest anfrage, ClaimsPrincipal wer, 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(); 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) if (await DeliveryEndpoints.FreiAb(db, gewuenschte, uhr.GetUtcNow()) is { } nochNicht)
return DeliveryEndpoints.NochNicht(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 profil = Profil(anfrage);
var hoehe = anfrage.Height is { } gewuenscht var hoehe = anfrage.Height is { } gewuenscht
&& Stromentscheid.Stufen.Contains(gewuenscht) ? gewuenscht : (int?)null; && 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 // im Spieler: bei „Fortsetzen ab 8:37" stand der Satz von Sekunde 0
// auf dem Bild. // auf dem Bild.
app.MapGet("/api/v1/files/{id:guid}/subtitles/{spur:int}.vtt", async ( 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) => CancellationToken ct, double ab = 0) =>
{ {
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id, ct); 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); var spuren = await Textspuren(db, id, ct);
if (vtt is null) return Results.NotFound(); if (!spuren.Contains(spur))
return Einwand.Fehlt("Diese Spur enthält keinen Text, sondern Bilder.");
return Results.Text( var (stand, text) = await vorrat.HolenAsync(
Untertitelversatz.Verschieben(vtt, Math.Max(0, ab)), "text/vtt", Encoding.UTF8); 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(); }).RequireAuthorization();
} }
@@ -342,6 +375,24 @@ public static class StreamEndpoints
private static bool IstBild(string codec) => private static bool IstBild(string codec) =>
codec is "hdmv_pgs_subtitle" or "pgssub" or "dvd_subtitle" or "dvdsub" or "xsub"; 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> /// <summary>Wie eine Spur im Menü heisst.</summary>
private static string Benennung(MediaStream spur) private static string Benennung(MediaStream spur)
{ {
+1
View File
@@ -111,6 +111,7 @@ builder.Services.AddSingleton<PackageCoordinator>();
// zum Mitnehmen; dieser lässt sie laufen, während sie entsteht. // zum Mitnehmen; dieser lässt sie laufen, während sie entsteht.
builder.Services.AddSingleton<Streamkoordinator>(); builder.Services.AddSingleton<Streamkoordinator>();
builder.Services.AddSingleton<UntertitelLeser>(); builder.Services.AddSingleton<UntertitelLeser>();
builder.Services.AddSingleton<Untertitelvorrat>();
// Im Arbeitsspeicher und nur dort: wer gerade was schaut, ist eine Frage // Im Arbeitsspeicher und nur dort: wer gerade was schaut, ist eine Frage
// ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand. // ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand.
builder.Services.AddSingleton<PlaybackSessions>(); builder.Services.AddSingleton<PlaybackSessions>();
+113
View File
@@ -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);
}
}
}
+111 -32
View File
@@ -3,7 +3,7 @@ using Microsoft.Extensions.Logging;
namespace Flaro.Media; namespace Flaro.Media;
/// <summary>Holt eine Untertitelspur als WebVTT aus einer Datei.</summary> /// <summary>Holt Untertitelspuren als WebVTT aus einer Datei.</summary>
/// ///
/// <remarks> /// <remarks>
/// <para> /// <para>
@@ -18,24 +18,55 @@ namespace Flaro.Media;
/// zu machen hiesse, Schrift zu erkennen. Sie werden gar nicht erst /// zu machen hiesse, Schrift zu erkennen. Sie werden gar nicht erst
/// angeboten — siehe <c>StreamEndpoints</c>. /// angeboten — siehe <c>StreamEndpoints</c>.
/// </para> /// </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> /// </remarks>
public sealed class UntertitelLeser(FfmpegOptions optionen, ILogger<UntertitelLeser> log) 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> /// <remarks>
/// Untertitel sind ein paar Dutzend Kilobyte; braucht es dafür länger als /// <b>Grosszügig, weil es einmal geschieht.</b> Vorher standen hier
/// eine halbe Minute, stimmt etwas mit der Datei nicht — und ein Spieler, /// dreissig Sekunden, mit der Begründung, Untertitel seien ein paar
/// der ewig auf einen Text wartet, ist schlimmer als einer ohne. /// 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> /// </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> /// <summary>Eine Spur und die Datei, in die sie soll.</summary>
public async Task<string?> AlsWebVttAsync(string datei, int spur, CancellationToken ct = default) 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); using var zeitlimit = CancellationTokenSource.CreateLinkedTokenSource(ct);
zeitlimit.CancelAfter(Geduld); zeitlimit.CancelAfter(Geduld);
foreach (var auftrag in auftraege)
{
Directory.CreateDirectory(Path.GetDirectoryName(auftrag.Ziel)!);
}
var start = new ProcessStartInfo(optionen.Ffmpeg) var start = new ProcessStartInfo(optionen.Ffmpeg)
{ {
RedirectStandardOutput = true, RedirectStandardOutput = true,
@@ -43,19 +74,27 @@ public sealed class UntertitelLeser(FfmpegOptions optionen, ILogger<UntertitelLe
UseShellExecute = false, UseShellExecute = false,
}; };
foreach (var a in new[] // **Ein Eingang, mehrere Ausgänge.** ffmpeg liest die Datei dabei
{ // genau einmal — das ist der teure Teil, und er wird nicht je Spur
"-loglevel", "error", // bezahlt.
"-i", datei, start.ArgumentList.Add("-loglevel");
"-map", $"0:{spur}", start.ArgumentList.Add("error");
"-c:s", "webvtt", start.ArgumentList.Add("-y");
"-f", "webvtt", start.ArgumentList.Add("-i");
// Auf die Standardausgabe statt in eine Datei: es geht um start.ArgumentList.Add(datei);
// Kilobyte, und eine Datei müsste wieder weggeräumt werden.
"pipe:1", 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 try
@@ -63,7 +102,6 @@ public sealed class UntertitelLeser(FfmpegOptions optionen, ILogger<UntertitelLe
using var prozess = Process.Start(start) using var prozess = Process.Start(start)
?? throw new InvalidOperationException($"{optionen.Ffmpeg} liess sich nicht starten."); ?? throw new InvalidOperationException($"{optionen.Ffmpeg} liess sich nicht starten.");
var lesen = prozess.StandardOutput.ReadToEndAsync(zeitlimit.Token);
var fehlerLesen = prozess.StandardError.ReadToEndAsync(zeitlimit.Token); var fehlerLesen = prozess.StandardError.ReadToEndAsync(zeitlimit.Token);
try try
@@ -76,24 +114,65 @@ public sealed class UntertitelLeser(FfmpegOptions optionen, ILogger<UntertitelLe
throw; throw;
} }
var text = await lesen;
if (prozess.ExitCode != 0) if (prozess.ExitCode != 0)
{ {
log.LogDebug("Untertitelspur {Spur} aus {Datei}: {Fehler}", log.LogDebug("Untertitel aus {Datei}: {Fehler}",
spur, Path.GetFileName(datei), (await fehlerLesen).Trim()); Path.GetFileName(datei), (await fehlerLesen).Trim());
return null;
} }
// **Eine leere Antwort ist keine Antwort.** ffmpeg gibt bei einer return Abnehmen(auftraege);
// Bildspur eine gültige, aber inhaltslose WebVTT-Datei aus; sie
// auszuliefern hiesse, leere Untertitel anzubieten.
return text.Contains("-->", StringComparison.Ordinal) ? text : null;
} }
catch (OperationCanceledException) when (!ct.IsCancellationRequested) catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{ {
log.LogWarning("Untertitelspur {Spur} aus {Datei} brauchte länger als {Geduld}.", log.LogWarning("Untertitel aus {Datei} brauchten länger als {Geduld}.",
spur, Path.GetFileName(datei), Geduld); Path.GetFileName(datei), Geduld);
return null; 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 */ }
} }
} }
} }
+11 -3
View File
@@ -59,8 +59,16 @@ public static partial class Untertitelversatz
if (zeile.Trim().Length == 0) if (zeile.Trim().Length == 0)
{ {
if (behalten) foreach (var b in block) heraus.Append(b).Append('\n'); // **Die Trennzeile gehört dem Block, nicht der Datei.** Fällt
heraus.Append('\n'); // 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(); block.Clear();
behalten = true; behalten = true;
continue; continue;
@@ -82,7 +90,7 @@ public static partial class Untertitelversatz
block.Add(zeile); 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(); return heraus.ToString();
} }
@@ -0,0 +1,100 @@
using Microsoft.Extensions.Logging.Abstractions;
namespace Flaro.Media.Tests;
/// <summary>Untertitel aus einer Datei holen und ablegen.</summary>
///
/// <remarks>
/// <b>Der Anlass.</b> Im Spieler stand „Diese Untertitelspur liess sich nicht
/// laden", und auf der zweiten Spur kam gar nichts. Nicht das Format war
/// schuld, sondern die Zeit: jeder Abruf startete ffmpeg, ffmpeg las die
/// ganze Datei, und nach dreissig Sekunden wurde abgebrochen. An einem
/// Prüffilm von fünfzehn Megabyte fällt das nie auf — deshalb prüft hier
/// nicht die Dauer, sondern das Verfahren: ein Durchlauf für alle Spuren,
/// und was herauskommt, bleibt liegen.
/// </remarks>
[TestFixture]
public class UntertitelLeserTests
{
private string _ordner = "";
[SetUp]
public void Anlegen() => _ordner = Directory.CreateDirectory(
Path.Combine(Path.GetTempPath(), "flaro-ut-" + Guid.CreateVersion7().ToString("N"))).FullName;
[TearDown]
public void Aufraeumen()
{
try { Directory.Delete(_ordner, recursive: true); } catch (IOException) { /* Kür */ }
}
private UntertitelLeser Leser() => new(
new FfmpegOptions(Testdateien.Suchen("ffmpeg")!, Testdateien.Suchen("ffprobe")!),
NullLogger<UntertitelLeser>.Instance);
/// <summary>Die Spur landet als WebVTT auf der Platte.</summary>
[Test]
public async Task Eine_Textspur_wird_abgelegt()
{
if (!Testdateien.FfmpegVorhanden) Assert.Ignore("Ohne ffmpeg nicht prüfbar.");
var film = Path.Combine(_ordner, "film.mkv");
Testdateien.MitZweiTonspuren(film);
var ziel = Path.Combine(_ordner, "ablage", "spur3.vtt");
// Bild 0, Ton 1 und 2, Untertitel 3 — so legt der Erzeuger sie an.
var fertig = await Leser().ZiehenAsync(film, [new UntertitelLeser.Auftrag(3, ziel)]);
Assert.Multiple(() =>
{
Assert.That(fertig, Is.EquivalentTo(new[] { 3 }));
Assert.That(File.Exists(ziel), Is.True, "die Spur muss liegen bleiben");
Assert.That(File.ReadAllText(ziel), Does.Contain("-->"));
Assert.That(File.ReadAllText(ziel), Does.Contain("Ein Untertitel"));
});
}
/// <summary>Nichts Halbes bleibt liegen.</summary>
///
/// <remarks>
/// Geschrieben wird unter <c>.teil</c> und erst danach umbenannt. Bliebe
/// eine abgebrochene Datei am richtigen Namen liegen, gälte sie für immer
/// als fertig — und niemand käme je wieder an die vollständige Spur.
/// </remarks>
[Test]
public async Task Keine_Teildatei_bleibt_zurueck()
{
if (!Testdateien.FfmpegVorhanden) Assert.Ignore("Ohne ffmpeg nicht prüfbar.");
var film = Path.Combine(_ordner, "film.mkv");
Testdateien.MitZweiTonspuren(film);
var ziel = Path.Combine(_ordner, "ablage", "spur3.vtt");
await Leser().ZiehenAsync(film, [new UntertitelLeser.Auftrag(3, ziel)]);
Assert.That(Directory.GetFiles(Path.GetDirectoryName(ziel)!, "*.teil"), Is.Empty);
}
/// <summary>Eine Spur ohne Text hinterlässt eine leere Datei, keine fehlende.</summary>
///
/// <remarks>
/// <b>Die leere Datei ist die Antwort „hier ist nichts".</b> Ohne sie
/// würde bei jedem Abruf aufs Neue die ganze Datei gelesen, um wieder
/// nichts zu finden — und der Spieler bekäme nie eine Auskunft, sondern
/// immer nur eine Wartezeit.
/// </remarks>
[Test]
public async Task Eine_Tonspur_ergibt_eine_leere_Ablage()
{
if (!Testdateien.FfmpegVorhanden) Assert.Ignore("Ohne ffmpeg nicht prüfbar.");
var film = Path.Combine(_ordner, "film.mkv");
Testdateien.MitZweiTonspuren(film);
var ziel = Path.Combine(_ordner, "ablage", "spur1.vtt");
// Spur 1 ist Ton. ffmpeg bricht ab, und das Ergebnis ist kein Text.
var fertig = await Leser().ZiehenAsync(film, [new UntertitelLeser.Auftrag(1, ziel)]);
Assert.That(fertig, Is.Empty);
}
}
@@ -78,6 +78,28 @@ public class UntertitelversatzTests
}); });
} }
/// <summary>Weggefallene Sätze hinterlassen keine Leerzeilen.</summary>
///
/// <remarks>
/// <b>Gemessen am echten Film.</b> Bei einem Einstieg nach achteinhalb
/// Minuten standen vor dem ersten Satz 52 leere Zeilen — eine je
/// weggefallenem Block. Lesbar war das noch, sauber nicht: die Trennzeile
/// gehört dem Block und fällt mit ihm weg.
/// </remarks>
[Test]
public void Weggefallene_Saetze_hinterlassen_keine_Leerzeilen()
{
var heraus = Untertitelversatz.Verschieben(Probe, 10);
Assert.Multiple(() =>
{
Assert.That(heraus, Does.StartWith("WEBVTT\n\n2\n00:00:00.000"),
"nach dem Kopf kommt sofort der erste Satz, der noch gilt");
Assert.That(heraus, Does.Not.Contain("\n\n\n"),
"zwei Leerzeilen hintereinander heisst, dass ein Block seine Trennzeile zurückliess");
});
}
[Test] [Test]
public void Der_Kopf_bleibt_stehen() => public void Der_Kopf_bleibt_stehen() =>
Assert.That(Untertitelversatz.Verschieben(Probe, 10), Does.StartWith("WEBVTT")); Assert.That(Untertitelversatz.Verschieben(Probe, 10), Does.StartWith("WEBVTT"));
+42
View File
@@ -672,6 +672,48 @@ export const untertitelAdresse = (datei: string, spur: number, vorlauf = 0) =>
`/api/v1/files/${datei}/subtitles/${spur}.vtt` `/api/v1/files/${datei}/subtitles/${spur}.vtt`
+ (vorlauf > 0 ? `?ab=${vorlauf.toFixed(3)}` : ''); + (vorlauf > 0 ? `?ab=${vorlauf.toFixed(3)}` : '');
/** Was beim Holen einer Untertitelspur herauskam. */
export type Untertitelstand =
| { art: 'da'; text: string }
| { art: 'laeuft' }
| { art: 'fehler'; satz: string };
/** Holt eine Untertitelspur — und unterscheidet „geht nicht" von „dauert noch".
*
* **Warum nicht einfach `track.src`.** Dann lädt der Browser selbst, und im
* Fehlerfall sagt er nur „error" — ohne Unterschied zwischen „diese Spur gibt
* es nicht", „sie enthält keinen Text" und „der Server holt sie gerade erst
* aus der Datei". Der letzte Fall ist keine Seltenheit: einen Untertitel aus
* einem Dreistünder zu ziehen heisst, die ganze Datei zu lesen, und das
* dauert beim ersten Mal Minuten.
*
* **Und mit Token, nicht mit Keks.** Ein `<track>` wies sich über das Cookie
* aus. Das geht, solange es eines gibt — gemessen fiel es auf, als keines da
* war und jeder Abruf mit 401 zurückkam, ohne dass irgendwo etwas stand. */
export async function untertitelHolen(
datei: string, spur: number, vorlauf = 0,
): Promise<Untertitelstand> {
const kopf = new Headers();
if (sitzung.token) kopf.set('Authorization', `Bearer ${sitzung.token}`);
let antwort: Response;
try {
antwort = await fetch(untertitelAdresse(datei, spur, vorlauf), { headers: kopf });
} catch {
return { art: 'fehler', satz: 'Der Server war nicht erreichbar.' };
}
if (antwort.ok) return { art: 'da', text: await antwort.text() };
if (antwort.status === 503) return { art: 'laeuft' };
// Der Server schreibt seinen Satz nach `detail`; steht dort nichts, bleibt
// ein eigener — ein Fehlercode ist keine Auskunft.
const satz = await antwort.json()
.then((r: { detail?: string }) => r.detail)
.catch(() => undefined);
return { art: 'fehler', satz: satz || 'Diese Untertitelspur liess sich nicht laden.' };
}
/* ---------- Verwaltung ---------- */ /* ---------- Verwaltung ---------- */
export const bibliotheken = () => anfrage<Bibliothek[]>('/libraries'); export const bibliotheken = () => anfrage<Bibliothek[]>('/libraries');
+55 -12
View File
@@ -1,6 +1,6 @@
import type HlsTyp from 'hls.js'; import type HlsTyp from 'hls.js';
import { import {
stromStarten, stromBeenden, untertitelAdresse, fortschritt, stromStarten, stromBeenden, untertitelHolen, fortschritt,
type Stromauskunft, type Spurauskunft, type Stromauskunft, type Spurauskunft,
} from './api'; } from './api';
import { dauer, knopf, meldung, hinweisZeigen } from './util'; import { dauer, knopf, meldung, hinweisZeigen } from './util';
@@ -751,10 +751,21 @@ export function spieler(anfrage: Spieleranfrage): void {
? 'showing' : 'disabled'; ? 'showing' : 'disabled';
}); });
menuesAuffrischen(); menuesAuffrischen();
// Geholt wird erst beim Einschalten: einen Untertitel aus einer grossen
// Datei zu ziehen kostet den Server das Lesen der ganzen Datei, und die
// meisten Spuren schaltet nie jemand ein.
if (spur !== null) void spurAnhaengen(spur);
} }
/** Die Adressen der schon geholten Spuren — damit nichts zweimal läuft. */
const geholt = new Map<number, string>();
function untertitelAnhaengen(): void { function untertitelAnhaengen(): void {
video.querySelectorAll('track').forEach(t => t.remove()); video.querySelectorAll('track').forEach(t => t.remove());
geholt.forEach(adresse => URL.revokeObjectURL(adresse));
geholt.clear();
(auskunft?.subtitleStreams ?? []).forEach(s => { (auskunft?.subtitleStreams ?? []).forEach(s => {
const t = document.createElement('track'); const t = document.createElement('track');
t.kind = 'subtitles'; t.kind = 'subtitles';
@@ -764,23 +775,55 @@ export function spieler(anfrage: Spieleranfrage): void {
// wäre „de". Lieber keine Angabe als eine falsche: die Beschriftung // wäre „de". Lieber keine Angabe als eine falsche: die Beschriftung
// steht ohnehin in `label`. // steht ohnehin in `label`.
if (s.language?.length === 2) t.srclang = s.language; if (s.language?.length === 2) t.srclang = s.language;
// Mit dem Vorlauf: der Strom beginnt bei null, die Datei nicht.
t.src = untertitelAdresse(anfrage.dateiId, s.index, vorlauf);
t.dataset.spur = String(s.index); t.dataset.spur = String(s.index);
// **Ein gescheiterter Abruf war bisher vollkommen stumm.** Der Haken
// stand im Menü, als liefe es — und niemand konnte unterscheiden, ob
// die Spur leer ist oder der Abruf schiefging.
t.onerror = () => {
if (untertitel === s.index) hinweisZeigen('Diese Untertitelspur liess sich nicht laden.', 'fehler');
};
video.appendChild(t); video.appendChild(t);
}); });
if (untertitel !== null) untertitelWaehlen(untertitel); if (untertitel !== null) untertitelWaehlen(untertitel);
} }
/** Holt eine Spur und hängt sie an — mit Geduld, wenn der Server noch zieht.
*
* Der fertige Text geht als Blob an den `<track>`: dieselbe Anzeige wie
* vorher, nur mit einem Weg dahin, der sagen kann, was los ist. Warum
* nicht `track.src`, steht bei [untertitelHolen] in `api.ts`. */
async function spurAnhaengen(spur: number): Promise<void> {
if (geholt.has(spur)) return;
const element = [...video.querySelectorAll('track')]
.find(t => t.dataset.spur === String(spur));
if (!element) return;
// Rund zwei Minuten lang nachfragen. Wer länger braucht, hat ein
// Problem, das kein Warten löst — und bekommt es dann auch gesagt.
for (let versuch = 0; versuch < 40; versuch++) {
if (geschlossen || untertitel !== spur) return;
const stand = await untertitelHolen(anfrage.dateiId, spur, vorlauf);
if (stand.art === 'da') {
const blob = URL.createObjectURL(new Blob([stand.text], { type: 'text/vtt' }));
geholt.set(spur, blob);
element.src = blob;
// Das Anhängen setzt den Modus zurück; nach dem Laden noch einmal
// setzen, sonst bliebe die Spur stumm im Hintergrund.
if (untertitel === spur) untertitelWaehlen(spur);
return;
}
if (stand.art === 'fehler') {
hinweisZeigen(stand.satz, 'fehler');
return;
}
if (versuch === 0) {
hinweisZeigen('Die Untertitel werden aus der Datei geholt — das dauert beim ersten Mal.');
}
await new Promise(r => setTimeout(r, 3000));
}
hinweisZeigen('Die Untertitel brauchen zu lange. Versuche es später noch einmal.', 'fehler');
}
/* ---------- Fehler ---------- */ /* ---------- Fehler ---------- */
/** Zweiter Versuch, diesmal gerechnet. /** Zweiter Versuch, diesmal gerechnet.