using Flaro.Core.Library; using Flaro.Data; using Flaro.Media; using Microsoft.EntityFrameworkCore; namespace Flaro.Api; /// Herausgezogene Untertitel — vorher geholt, nicht beim Einschalten. /// /// /// /// 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. /// /// /// 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. /// /// /// 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, IServiceScopeFactory scopes, PlaybackSessions laufende, ILogger log) { /// Wie lange gewartet wird, solange jemand zusieht. /// /// /// Die laufende Wiedergabe ist wichtiger als der Vorrat. Gelesen /// wird von derselben Platte, von der auch der Film kommt und die /// Startseite ihre Bilder holt — und eine Datei zu lesen heisst hier, /// vierzig Gigabyte zu lesen. Der Vorrat hat Zeit, der Zuschauer nicht. /// Die Wiedergabesitzungen verfallen nach einer Minute ohne Meldung, ein /// halbminütiger Blick trifft das Ende also knapp genug. /// private static readonly TimeSpan Rücksicht = TimeSpan.FromSeconds(30); /// 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 geholt oder wartet in der Reihe. Laeuft, } /// 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"); /// Stellt eine Datei an — vorn, wenn jemand auf sie wartet. public void Anstellen(Guid dateiId, bool vordraengeln = false) { lock (_schloss) { 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 . /// /// /// 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. /// public async Task<(Stand Stand, string Text)> HolenAsync( 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); 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) { Guid naechste; lock (_schloss) { if (_reihe.First is null) { _arbeitet = false; Laufende = null; return; } naechste = _reihe.First.Value; _reihe.RemoveFirst(); Laufende = naechste; } // **Erst nachsehen, ob jemand zusieht.** Zurückgestellt, nicht // verworfen: die Datei bleibt vorn in der Reihe und kommt dran, // sobald der Abspann läuft. while (laufende.Laufende().Count > 0) { lock (_schloss) { _reihe.AddFirst(naechste); Laufende = null; } await Task.Delay(Rücksicht); lock (_schloss) { if (_reihe.First is null) { _arbeitet = false; return; } naechste = _reihe.First.Value; _reihe.RemoveFirst(); Laufende = naechste; } } try { await Ziehen(naechste); } catch (Exception fehler) { log.LogWarning(fehler, "Untertitel der Datei {Kennung} liessen sich nicht holen.", naechste); } // **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"; }