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 ---------- */