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 `` 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 { + 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 ---------- */ export const bibliotheken = () => anfrage('/libraries'); diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts index 1193842..07511e7 100644 --- a/web/app/src/spieler.ts +++ b/web/app/src/spieler.ts @@ -1,6 +1,6 @@ import type HlsTyp from 'hls.js'; import { - stromStarten, stromBeenden, untertitelAdresse, fortschritt, + stromStarten, stromBeenden, untertitelHolen, fortschritt, type Stromauskunft, type Spurauskunft, } from './api'; import { dauer, knopf, meldung, hinweisZeigen } from './util'; @@ -751,10 +751,21 @@ export function spieler(anfrage: Spieleranfrage): void { ? 'showing' : 'disabled'; }); 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(); + function untertitelAnhaengen(): void { video.querySelectorAll('track').forEach(t => t.remove()); + geholt.forEach(adresse => URL.revokeObjectURL(adresse)); + geholt.clear(); + (auskunft?.subtitleStreams ?? []).forEach(s => { const t = document.createElement('track'); t.kind = 'subtitles'; @@ -764,23 +775,55 @@ export function spieler(anfrage: Spieleranfrage): void { // wäre „de". Lieber keine Angabe als eine falsche: die Beschriftung // steht ohnehin in `label`. 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); - - // **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); }); 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 ``: 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 { + 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 ---------- */ /** Zweiter Versuch, diesmal gerechnet.