diff --git a/scratchpad/neu/data/flaro.db b/scratchpad/neu/data/flaro.db
index c9cf4b4..719a7a9 100644
Binary files a/scratchpad/neu/data/flaro.db and b/scratchpad/neu/data/flaro.db differ
diff --git a/scratchpad/neu/data/flaro.db-shm b/scratchpad/neu/data/flaro.db-shm
deleted file mode 100644
index 9560449..0000000
Binary files a/scratchpad/neu/data/flaro.db-shm and /dev/null differ
diff --git a/scratchpad/neu/data/flaro.db-wal b/scratchpad/neu/data/flaro.db-wal
deleted file mode 100644
index 1536221..0000000
Binary files a/scratchpad/neu/data/flaro.db-wal and /dev/null differ
diff --git a/scratchpad/neu/data/logs/flaro-2026-10-07.log b/scratchpad/neu/data/logs/flaro-2026-10-07.log
index 98b18bf..816b658 100644
--- a/scratchpad/neu/data/logs/flaro-2026-10-07.log
+++ b/scratchpad/neu/data/logs/flaro-2026-10-07.log
@@ -1 +1,2 @@
21:35:00 INFO Server gestartet — 1.0.0 · DEV
+21:55:29 INFO Server fährt herunter.
diff --git a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs
index 605f860..4f617d9 100644
--- a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs
@@ -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";
+ /// Die Spurnummern, aus denen sich Text holen lässt.
+ ///
+ ///
+ /// Dieselbe Auswahl, die auch im Menü steht — sonst böte der Spieler eine
+ /// Spur an, die der Vorrat gar nicht erst zieht.
+ ///
+ private static async Task> 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();
+ }
+
/// Wie eine Spur im Menü heisst.
private static string Benennung(MediaStream spur)
{
diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs
index 8b7baba..0989bba 100644
--- a/server/src/Flaro.Api/Program.cs
+++ b/server/src/Flaro.Api/Program.cs
@@ -111,6 +111,7 @@ builder.Services.AddSingleton();
// zum Mitnehmen; dieser lässt sie laufen, während sie entsteht.
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
// 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();
diff --git a/server/src/Flaro.Api/Untertitelvorrat.cs b/server/src/Flaro.Api/Untertitelvorrat.cs
new file mode 100644
index 0000000..9fe517d
--- /dev/null
+++ b/server/src/Flaro.Api/Untertitelvorrat.cs
@@ -0,0 +1,113 @@
+using System.Collections.Concurrent;
+using Flaro.Media;
+
+namespace Flaro.Api;
+
+/// Herausgezogene Untertitel, abgelegt für den zweiten Abruf.
+///
+///
+///
+/// Der Anlass. 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.
+///
+///
+/// Und es wurde schlimmer, bevor es besser wurde. Seit die Adresse den
+/// Einstiegspunkt mitführt (?ab=), ä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.
+///
+///
+/// Warm, bevor jemand fragt. 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.
+///
+///
+public sealed class Untertitelvorrat(FlaroPaths pfade, UntertitelLeser leser)
+{
+ /// Was ein Abruf vorfindet.
+ public enum Stand
+ {
+ /// Liegt da, mit Text.
+ Fertig,
+
+ /// Liegt da und ist leer — diese Spur hat keinen Text.
+ Leer,
+
+ /// Wird gerade herausgezogen.
+ Laeuft,
+ }
+
+ /// Ein Lauf je Datei, nicht je Spur — er holt ohnehin alle.
+ private readonly ConcurrentDictionary _laeuft = new();
+
+ public string Pfad(Guid dateiId, DateTimeOffset geaendert, int spur) =>
+ Path.Combine(pfade.Transcode, "untertitel",
+ $"{dateiId:N}-{geaendert.ToUnixTimeMilliseconds()}-{spur}.vtt");
+
+ /// Stösst das Herausziehen an, wenn etwas fehlt — und kehrt sofort zurück.
+ public void Anstossen(Guid dateiId, string quelle, DateTimeOffset geaendert, IReadOnlyList 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? _); }
+ }));
+ }
+
+ /// Holt eine Spur — und wartet dabei höchstens .
+ ///
+ ///
+ /// Warum überhaupt gewartet wird. 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.
+ ///
+ public async Task<(Stand Stand, string Text)> HolenAsync(
+ Guid dateiId, string quelle, DateTimeOffset geaendert,
+ IReadOnlyList 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);
+ }
+ }
+}
diff --git a/server/src/Flaro.Media/UntertitelLeser.cs b/server/src/Flaro.Media/UntertitelLeser.cs
index e94df2a..59c11aa 100644
--- a/server/src/Flaro.Media/UntertitelLeser.cs
+++ b/server/src/Flaro.Media/UntertitelLeser.cs
@@ -3,7 +3,7 @@ using Microsoft.Extensions.Logging;
namespace Flaro.Media;
-/// Holt eine Untertitelspur als WebVTT aus einer Datei.
+/// Holt Untertitelspuren als WebVTT aus einer Datei.
///
///
///
@@ -18,24 +18,55 @@ namespace Flaro.Media;
/// zu machen hiesse, Schrift zu erkennen. Sie werden gar nicht erst
/// angeboten — siehe StreamEndpoints.
///
+///
+/// Alle Spuren in einem Durchlauf, und warum das der ganze Unterschied
+/// ist. 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 Untertitelvorrat sorgt dafür, dass es bei diesem einen bleibt.
+///
///
public sealed class UntertitelLeser(FfmpegOptions optionen, ILogger log)
{
- /// Wie lange das Herausziehen höchstens dauern darf.
+ /// Wie lange ein Durchlauf höchstens dauern darf.
///
///
- /// 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.
+ /// Grosszügig, weil es einmal geschieht. 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.
///
- private static readonly TimeSpan Geduld = TimeSpan.FromSeconds(30);
+ private static readonly TimeSpan Geduld = TimeSpan.FromMinutes(30);
- /// null, wenn es die Spur nicht gibt oder sie kein Text ist.
- public async Task AlsWebVttAsync(string datei, int spur, CancellationToken ct = default)
+ /// Eine Spur und die Datei, in die sie soll.
+ public readonly record struct Auftrag(int Spur, string Ziel);
+
+ /// Zieht die genannten Spuren heraus und legt sie ab.
+ ///
+ ///
+ /// 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.
+ ///
+ public async Task> ZiehenAsync(
+ string datei, IReadOnlyList 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", 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 [];
+ }
+ }
+
+ /// Nimmt ab, was brauchbar ist, und rückt es an seinen Platz.
+ ///
+ ///
+ /// Erst unter .teil, dann umbenennen. 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.
+ ///
+ private static IReadOnlyList Abnehmen(IReadOnlyList auftraege)
+ {
+ var fertig = new List();
+
+ 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 auftraege)
+ {
+ foreach (var auftrag in auftraege)
+ {
+ try { File.Delete(auftrag.Ziel + ".teil"); } catch (IOException) { /* Kür */ }
}
}
}
diff --git a/server/src/Flaro.Media/Untertitelversatz.cs b/server/src/Flaro.Media/Untertitelversatz.cs
index 919aa60..2f1de0e 100644
--- a/server/src/Flaro.Media/Untertitelversatz.cs
+++ b/server/src/Flaro.Media/Untertitelversatz.cs
@@ -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();
}
diff --git a/server/tests/Flaro.Media.Tests/UntertitelLeserTests.cs b/server/tests/Flaro.Media.Tests/UntertitelLeserTests.cs
new file mode 100644
index 0000000..7765f1c
--- /dev/null
+++ b/server/tests/Flaro.Media.Tests/UntertitelLeserTests.cs
@@ -0,0 +1,100 @@
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Flaro.Media.Tests;
+
+/// Untertitel aus einer Datei holen und ablegen.
+///
+///
+/// Der Anlass. 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.
+///
+[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.Instance);
+
+ /// Die Spur landet als WebVTT auf der Platte.
+ [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"));
+ });
+ }
+
+ /// Nichts Halbes bleibt liegen.
+ ///
+ ///
+ /// Geschrieben wird unter .teil 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.
+ ///
+ [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);
+ }
+
+ /// Eine Spur ohne Text hinterlässt eine leere Datei, keine fehlende.
+ ///
+ ///
+ /// Die leere Datei ist die Antwort „hier ist nichts". 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.
+ ///
+ [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);
+ }
+}
diff --git a/server/tests/Flaro.Media.Tests/UntertitelversatzTests.cs b/server/tests/Flaro.Media.Tests/UntertitelversatzTests.cs
index cdd16e9..97aa7d7 100644
--- a/server/tests/Flaro.Media.Tests/UntertitelversatzTests.cs
+++ b/server/tests/Flaro.Media.Tests/UntertitelversatzTests.cs
@@ -78,6 +78,28 @@ public class UntertitelversatzTests
});
}
+ /// Weggefallene Sätze hinterlassen keine Leerzeilen.
+ ///
+ ///
+ /// Gemessen am echten Film. 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.
+ ///
+ [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]
public void Der_Kopf_bleibt_stehen() =>
Assert.That(Untertitelversatz.Verschieben(Probe, 10), Does.StartWith("WEBVTT"));
diff --git a/web/app/src/api.ts b/web/app/src/api.ts
index e74a912..5609cc5 100644
--- a/web/app/src/api.ts
+++ b/web/app/src/api.ts
@@ -672,6 +672,48 @@ export const untertitelAdresse = (datei: string, spur: number, vorlauf = 0) =>
`/api/v1/files/${datei}/subtitles/${spur}.vtt`
+ (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 `