From 1531163bc181f8f2c4d95ebeba7a054c88c95374 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 7 Oct 2026 22:14:20 +0200 Subject: [PATCH] Die Untertitel holen, bevor jemand sie einschaltet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Minuten „wird geholt", und noch immer kein Text. Die Ablage von vorhin war richtig und reichte nicht: beim ersten Mal musste immer noch jemand warten, und zwar der, der gerade einen Film anschaut. Das Lesen selbst lässt sich nicht beschleunigen. Untertitelpakete liegen über die ganze Datei verteilt; sie zu finden heisst, alles zu lesen. Bei vierzig Gigabyte auf einem NAS sind das Minuten, und daran ändert kein Schalter etwas. Also nicht schneller lesen, sondern früher. Der Vorrat arbeitet die Mediathek jetzt von sich aus durch: beim Start des Servers und nach jedem Scan, die zuletzt hinzugekommenen zuerst. Eine Datei nach der anderen, mit zwei Sekunden Pause dazwischen — das Lesen ist von der Platte begrenzt, und eine laufende Wiedergabe liest von derselben Platte und ist wichtiger. Wer einen Film startet, drängelt sich vor: seine Datei ist als Nächstes dran statt als Dreissigste. Einmal je Datei, nie wieder: der Name der Ablage trägt den Änderungszeitpunkt. Eine ersetzte Datei bekommt dadurch von selbst einen neuen Namen, eine unveränderte wird nie zweimal gelesen. Gemessen am Prüfserver mit leerer Ablage: Start, ohne jeden Abruf -> „1 Datei(en) werden im Hintergrund gelesen", danach „2 von 2 Spur(en)" Klick auf „Deutsch" -> 153 ms bis zum Text, 10 Einblendungen, die erste bei 0 bis 3 s Landet jemand trotzdem im Wartefall, gibt der Spieler nicht mehr nach zwei Minuten auf, sondern fragt zehn Minuten lang nach — und sagt dazu, dass es einmalig ist. Die Auswahl der Textspuren stand zweimal im Quelltext und steht jetzt einmal: im Vorrat. Zwei Listen, von denen eine das Menü füllt und die andere bestimmt, was geholt wird, laufen sonst irgendwann auseinander. Co-Authored-By: Claude Opus 5 --- .../Flaro.Api/Endpoints/StreamEndpoints.cs | 32 +-- server/src/Flaro.Api/Program.cs | 14 +- server/src/Flaro.Api/ScanCoordinator.cs | 8 + server/src/Flaro.Api/Untertitelvorrat.cs | 247 ++++++++++++++---- web/app/src/spieler.ts | 13 +- 5 files changed, 229 insertions(+), 85 deletions(-) diff --git a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs index 4f617d9..0e10876 100644 --- a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs @@ -53,8 +53,7 @@ public static class StreamEndpoints // 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)); + vorrat.Anstellen(gewuenschte.Id, vordraengeln: true); var profil = Profil(anfrage); var hoehe = anfrage.Height is { } gewuenscht @@ -238,13 +237,12 @@ public static class StreamEndpoints if (datei is null || !File.Exists(datei.Path)) return Einwand.Fehlt("Diese Datei liegt nicht mehr da."); - var spuren = await Textspuren(db, id, ct); + var spuren = await Untertitelvorrat.TextspurenAsync(db, id, ct); if (!spuren.Contains(spur)) return Einwand.Fehlt("Diese Spur enthält keinen Text, sondern Bilder."); var (stand, text) = await vorrat.HolenAsync( - datei.Id, datei.Path, datei.ModifiedAt, spuren, spur, - TimeSpan.FromSeconds(20), ct); + datei.Id, datei.ModifiedAt, spur, TimeSpan.FromSeconds(20), ct); return stand switch { @@ -308,7 +306,7 @@ public static class StreamEndpoints .Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault)) .ToList(), SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle) - .Where(s => s.Codec is null || !IstBild(s.Codec)) + .Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec)) .Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault)) .ToList()); } @@ -360,7 +358,7 @@ public static class StreamEndpoints .Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault)) .ToList(), SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle) - .Where(s => s.Codec is null || !IstBild(s.Codec)) + .Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec)) .Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault)) .ToList()); } @@ -372,26 +370,6 @@ public static class StreamEndpoints /// PGS ist auf jedem Blu-ray-Rip die Regel — ein Eintrag im Menü, der zu /// nichts führt, ist schlechter als kein Eintrag. /// - 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 0989bba..abeb87b 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -312,7 +312,19 @@ if (Directory.Exists(oberfläche)) var leben = app.Services.GetRequiredService(); leben.ApplicationStarted.Register(() => - tagebuch.Schreiben($"Server gestartet — {Buildkennung.Beides}")); + { + tagebuch.Schreiben($"Server gestartet — {Buildkennung.Beides}"); + + // **Die Untertitel holen, bevor jemand sie einschaltet.** + // + // Eine Spur aus einer Datei zu ziehen heisst, die Datei ganz zu + // lesen — bei einem Dreistünder auf einem NAS sind das Minuten. + // Das im Augenblick des Einschaltens zu tun hiess: zwei Minuten + // „wird geholt", und noch immer kein Text. Hier läuft es einmal je + // Datei durch, eine nach der anderen, während niemand wartet. + _ = app.Services.GetRequiredService() + .NachtragenAsync(leben.ApplicationStopping); + }); leben.ApplicationStopping.Register(() => tagebuch.Schreiben("Server fährt herunter.")); } diff --git a/server/src/Flaro.Api/ScanCoordinator.cs b/server/src/Flaro.Api/ScanCoordinator.cs index f564284..b79482f 100644 --- a/server/src/Flaro.Api/ScanCoordinator.cs +++ b/server/src/Flaro.Api/ScanCoordinator.cs @@ -152,6 +152,14 @@ public sealed class ScanCoordinator( zusammengelegt.Filme, zusammengelegt.Serien, libraryId); } + // **Untertitel gleich mitnehmen.** Sie aus einer Datei zu holen + // heisst, die Datei ganz zu lesen; das im Augenblick des + // Einschaltens zu tun heisst, den Zuschauer Minuten warten zu + // lassen. Der Vorrat arbeitet das im Hintergrund ab, eine Datei + // nach der anderen. + _ = bereich.ServiceProvider.GetRequiredService() + .NachtragenAsync(CancellationToken.None); + _zustand[libraryId] = _zustand[libraryId] with { Status = "finished", diff --git a/server/src/Flaro.Api/Untertitelvorrat.cs b/server/src/Flaro.Api/Untertitelvorrat.cs index 9fe517d..6dba6e8 100644 --- a/server/src/Flaro.Api/Untertitelvorrat.cs +++ b/server/src/Flaro.Api/Untertitelvorrat.cs @@ -1,9 +1,11 @@ -using System.Collections.Concurrent; +using Flaro.Core.Library; +using Flaro.Data; using Flaro.Media; +using Microsoft.EntityFrameworkCore; namespace Flaro.Api; -/// Herausgezogene Untertitel, abgelegt für den zweiten Abruf. +/// Herausgezogene Untertitel — vorher geholt, nicht beim Einschalten. /// /// /// @@ -11,24 +13,33 @@ namespace Flaro.Api; /// 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. +/// Und das Lesen lässt sich nicht beschleunigen. Untertitelpakete +/// liegen über die ganze Datei verteilt; sie zu finden heisst, alles zu +/// lesen. Bei vierzig Gigabyte auf einem NAS sind das Minuten, und daran +/// ändert kein Schalter etwas. Auch mit Ablage blieb deshalb der erste +/// Abruf eine Zumutung: zwei Minuten „wird geholt", und noch immer kein +/// Text. /// /// -/// 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. +/// Also vorher. Dieser Vorrat arbeitet die Mediathek von sich aus +/// durch — beim Start des Servers und nach jedem Scan, eine Datei nach der +/// anderen, damit die Platte nicht von zehn ffmpeg-Läufen zugleich belegt +/// wird. Wer einen Film startet, drängelt sich vor: sein Film ist dann als +/// Nächstes dran statt als Dreissigster. +/// +/// +/// Einmal je Datei, nie wieder. Der Name der Ablage trägt den +/// Änderungszeitpunkt der Datei. Eine ersetzte Datei bekommt dadurch von +/// selbst einen neuen Namen und wird neu gelesen; eine unveränderte nie. /// /// -public sealed class Untertitelvorrat(FlaroPaths pfade, UntertitelLeser leser) +public sealed class Untertitelvorrat( + FlaroPaths pfade, + UntertitelLeser leser, + IServiceScopeFactory scopes, + ILogger log) { /// Was ein Abruf vorfindet. public enum Stand @@ -39,38 +50,78 @@ public sealed class Untertitelvorrat(FlaroPaths pfade, UntertitelLeser leser) /// Liegt da und ist leer — diese Spur hat keinen Text. Leer, - /// Wird gerade herausgezogen. + /// Wird gerade geholt oder wartet in der Reihe. Laeuft, } - /// Ein Lauf je Datei, nicht je Spur — er holt ohnehin alle. - private readonly ConcurrentDictionary _laeuft = new(); + /// Die Reihe der noch zu lesenden Dateien. Vorn wird genommen. + private readonly LinkedList _reihe = new(); + + private readonly Lock _schloss = new(); + private bool _arbeitet; + + /// Welche Datei gerade gelesen wird — für das Protokoll. + public Guid? Laufende { get; private set; } + + /// Wie viele noch warten. + public int Wartend { get { lock (_schloss) return _reihe.Count; } } 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) + /// Stellt eine Datei an — vorn, wenn jemand auf sie wartet. + public void Anstellen(Guid dateiId, bool vordraengeln = false) { - 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 () => + lock (_schloss) { - try { await leser.ZiehenAsync(quelle, offen, CancellationToken.None); } - finally { _laeuft.TryRemove(dateiId, out Task? _); } - })); + if (Laufende == dateiId) return; + + var vorhanden = _reihe.Find(dateiId); + if (vorhanden is not null) + { + if (!vordraengeln) return; + _reihe.Remove(vorhanden); + } + + if (vordraengeln) _reihe.AddFirst(dateiId); + else _reihe.AddLast(dateiId); + + if (_arbeitet) return; + _arbeitet = true; + } + + _ = Task.Run(Abarbeiten); + } + + /// Stellt alles an, was noch keine Ablage hat. + /// + /// + /// Die zuletzt hinzugekommenen zuerst: was gerade in die Mediathek kam, + /// wird am ehesten als Nächstes geschaut. + /// + public async Task NachtragenAsync(CancellationToken ct = default) + { + using var raum = scopes.CreateScope(); + var db = raum.ServiceProvider.GetRequiredService(); + + var dateien = await db.MediaFiles.AsNoTracking() + .OrderByDescending(f => f.Id) + .Select(f => new { f.Id, f.ModifiedAt }) + .ToListAsync(ct); + + var offen = 0; + foreach (var datei in dateien) + { + var spuren = await TextspurenAsync(db, datei.Id, ct); + if (spuren.Count == 0) continue; + if (spuren.All(s => File.Exists(Pfad(datei.Id, datei.ModifiedAt, s)))) continue; + + Anstellen(datei.Id); + offen++; + } + + if (offen > 0) log.LogInformation("Untertitel: {Anzahl} Datei(en) werden im Hintergrund gelesen.", offen); } /// Holt eine Spur — und wartet dabei höchstens . @@ -79,35 +130,127 @@ public sealed class Untertitelvorrat(FlaroPaths pfade, UntertitelLeser leser) /// 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. + /// wartet, hängt hinter jedem Reverse Proxy ins Zeitlimit. /// public async Task<(Stand Stand, string Text)> HolenAsync( - Guid dateiId, string quelle, DateTimeOffset geaendert, - IReadOnlyList spuren, int spur, TimeSpan geduld, CancellationToken ct) + Guid dateiId, DateTimeOffset geaendert, int spur, TimeSpan geduld, CancellationToken ct) { var ziel = Pfad(dateiId, geaendert, spur); + if (File.Exists(ziel)) return await Lesen(ziel, ct); - Anstossen(dateiId, quelle, geaendert, spuren); + Anstellen(dateiId, vordraengeln: true); var bis = DateTimeOffset.UtcNow + geduld; + while (DateTimeOffset.UtcNow < bis) + { + await Task.Delay(TimeSpan.FromMilliseconds(400), ct); + if (File.Exists(ziel)) return await Lesen(ziel, ct); + + // Durch und trotzdem nichts da: diese Spur liess sich nicht + // herausziehen. Weiterzuwarten hiesse, auf etwas zu warten, das + // niemand mehr tut. + lock (_schloss) + { + if (Laufende != dateiId && _reihe.Find(dateiId) is null) return (Stand.Leer, ""); + } + } + + return (Stand.Laeuft, ""); + } + + private static async Task<(Stand, string)> Lesen(string ziel, CancellationToken ct) + { + var text = await File.ReadAllTextAsync(ziel, ct); + return (text.Length == 0 ? Stand.Leer : Stand.Fertig, text); + } + + /// Eine Datei nach der anderen, bis die Reihe leer ist. + /// + /// + /// Eine, nicht zehn. Das Lesen ist von der Platte begrenzt, nicht + /// vom Rechner. Zehn Läufe nebeneinander machen jeden einzelnen + /// langsamer und bringen die laufende Wiedergabe zum Stocken — und die + /// ist wichtiger als der Vorrat. + /// + private async Task Abarbeiten() + { while (true) { - if (File.Exists(ziel)) + Guid naechste; + lock (_schloss) { - var text = await File.ReadAllTextAsync(ziel, ct); - return (text.Length == 0 ? Stand.Leer : Stand.Fertig, text); + if (_reihe.First is null) + { + _arbeitet = false; + Laufende = null; + return; + } + + naechste = _reihe.First.Value; + _reihe.RemoveFirst(); + Laufende = naechste; } - // 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, ""); + try { await Ziehen(naechste); } + catch (Exception fehler) + { + log.LogWarning(fehler, "Untertitel der Datei {Kennung} liessen sich nicht holen.", naechste); + } - if (DateTimeOffset.UtcNow >= bis) return (Stand.Laeuft, ""); - - await Task.Delay(TimeSpan.FromMilliseconds(400), ct); + // **Luft holen zwischen zwei Dateien.** Der Vorrat liest die + // Platte am Stück leer; eine laufende Wiedergabe liest von + // derselben Platte und ist wichtiger. Zwei Sekunden kosten bei + // einem Lauf über Stunden nichts und nehmen den Druck heraus. + await Task.Delay(TimeSpan.FromSeconds(2)); } } + + private async Task Ziehen(Guid dateiId) + { + using var raum = scopes.CreateScope(); + var db = raum.ServiceProvider.GetRequiredService(); + + var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == dateiId); + if (datei is null || !File.Exists(datei.Path)) return; + + var spuren = await TextspurenAsync(db, dateiId, CancellationToken.None); + var offen = spuren + .Where(s => !File.Exists(Pfad(dateiId, datei.ModifiedAt, s))) + .Select(s => new UntertitelLeser.Auftrag(s, Pfad(dateiId, datei.ModifiedAt, s))) + .ToList(); + + if (offen.Count == 0) return; + + var begonnen = DateTimeOffset.UtcNow; + var fertig = await leser.ZiehenAsync(datei.Path, offen, CancellationToken.None); + + log.LogInformation( + "Untertitel aus {Datei}: {Fertig} von {Offen} Spur(en) in {Dauer:0.0} s. Noch {Rest} Datei(en).", + Path.GetFileName(datei.Path), fertig.Count, offen.Count, + (DateTimeOffset.UtcNow - begonnen).TotalSeconds, Wartend); + } + + /// Die Spurnummern, aus denen sich Text holen lässt. + /// + /// + /// Dieselbe Auswahl, die auch im Menü steht — sonst zöge der Vorrat + /// Spuren, die niemand anbietet, oder liesse welche aus, die jemand + /// einschaltet. Bildspuren wie PGS lassen sich nicht nach WebVTT + /// wandeln; das hiesse, Schrift zu erkennen. + /// + public static async Task> TextspurenAsync( + 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(); + } + + /// Bildbasierte Untertitel lassen sich nicht nach WebVTT wandeln. + public static bool IstBild(string codec) => + codec is "hdmv_pgs_subtitle" or "pgssub" or "dvd_subtitle" or "dvdsub" or "xsub"; } diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts index 07511e7..524d6fe 100644 --- a/web/app/src/spieler.ts +++ b/web/app/src/spieler.ts @@ -793,9 +793,12 @@ export function spieler(anfrage: Spieleranfrage): void { .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++) { + // **Lange nachfragen, und zwar absichtlich.** Der Server holt die Spur + // normalerweise schon im Hintergrund, bevor jemand sie einschaltet; + // landet man trotzdem hier, läuft gerade das Lesen einer grossen Datei, + // und das dauert Minuten. Aufzugeben hiesse, kurz vor dem Ziel + // abzubrechen — zehn Minuten sind der Preis dafür, dass es dann klappt. + for (let versuch = 0; versuch < 200; versuch++) { if (geschlossen || untertitel !== spur) return; const stand = await untertitelHolen(anfrage.dateiId, spur, vorlauf); @@ -816,12 +819,12 @@ export function spieler(anfrage: Spieleranfrage): void { } if (versuch === 0) { - hinweisZeigen('Die Untertitel werden aus der Datei geholt — das dauert beim ersten Mal.'); + hinweisZeigen('Diese Untertitel werden einmalig aus der Datei geholt. Bei einem grossen Film dauert das ein paar Minuten — danach sind sie sofort da.'); } await new Promise(r => setTimeout(r, 3000)); } - hinweisZeigen('Die Untertitel brauchen zu lange. Versuche es später noch einmal.', 'fehler'); + hinweisZeigen('Die Untertitel brauchen ungewöhnlich lange. Schau später noch einmal — im Hintergrund wird weitergelesen.', 'fehler'); } /* ---------- Fehler ---------- */