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";
}