Files
Flaro/server/src/Flaro.Api/Untertitelvorrat.cs
T
ScarriffleandClaude Opus 5 b88e58d5eb Das Gerät war da, die VA-Anzeige dahinter nicht
ffmpeg endete mit 234. No VA display found for device
    /dev/dri/renderD128. Device creation failed: -22.

Hier stand `File.Exists("/dev/dri/renderD128")`, und das war keine
Prüfung, sondern eine Vermutung. Auf dem NAS liegt das Gerät
durchgereicht da — eine VA-Anzeige steht nicht dahinter. ffmpeg bricht
dann schon an der globalen Option ab, vor dem ersten Bild, und für den
Zuschauer sieht das aus wie ein kaputter Server: HTTP 500 auf die
Abspielliste, „Die Wiedergabe wurde unterbrochen".

Jetzt wird ffmpeg einmal je Lauf wirklich gefragt: ein Bild aus dem
Nichts, nirgendwohin, mit Frist. Geht die Zeile vor dem ersten Bild nicht
durch, läuft es in Software weiter — langsamer, aber es läuft.
FLARO_HWACCEL=vaapi überspringt die Probe für den, der es ausdrücklich
verlangt.

Dazu, aus derselben Gegend: in der Hauptliste stehen nur Untertitelspuren,
die jetzt sofort abholbar sind. Sie werden beim ersten Bedarf aus der
Datei gezogen, und bei einem Dreistünder dauert das Minuten. Der Browser
verträgt das — er fragt die Spur einzeln an und wartet. AVPlayer bricht
daran die ganze Wiedergabe ab, nicht bloss den Untertitel. Das Füllen
läuft beim Anlegen der Sitzung schon an; beim nächsten Abspielen stehen
sie drin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 19:59:49 +02:00

305 lines
12 KiB
C#

using Flaro.Core.Library;
using Flaro.Data;
using Flaro.Media;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Herausgezogene Untertitel — vorher geholt, nicht beim Einschalten.</summary>
///
/// <remarks>
/// <para>
/// <b>Der Anlass.</b> 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.
/// </para>
/// <para>
/// <b>Und das Lesen lässt sich nicht beschleunigen.</b> 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.
/// </para>
/// <para>
/// <b>Also vorher.</b> 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.
/// </para>
/// <para>
/// <b>Einmal je Datei, nie wieder.</b> 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.
/// </para>
/// </remarks>
public sealed class Untertitelvorrat(
FlaroPaths pfade,
UntertitelLeser leser,
IServiceScopeFactory scopes,
PlaybackSessions laufende,
ILogger<Untertitelvorrat> log)
{
/// <summary>Wie lange gewartet wird, solange jemand zusieht.</summary>
///
/// <remarks>
/// <b>Die laufende Wiedergabe ist wichtiger als der Vorrat.</b> 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.
/// </remarks>
private static readonly TimeSpan Rücksicht = TimeSpan.FromSeconds(30);
/// <summary>Was ein Abruf vorfindet.</summary>
public enum Stand
{
/// <summary>Liegt da, mit Text.</summary>
Fertig,
/// <summary>Liegt da und ist leer — diese Spur hat keinen Text.</summary>
Leer,
/// <summary>Wird gerade geholt oder wartet in der Reihe.</summary>
Laeuft,
}
/// <summary>Die Reihe der noch zu lesenden Dateien. Vorn wird genommen.</summary>
private readonly LinkedList<Guid> _reihe = new();
private readonly Lock _schloss = new();
private bool _arbeitet;
/// <summary>Welche Datei gerade gelesen wird — für das Protokoll.</summary>
public Guid? Laufende { get; private set; }
/// <summary>Wie viele noch warten.</summary>
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");
/// <summary>Stellt eine Datei an — vorn, wenn jemand auf sie wartet.</summary>
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);
}
/// <summary>Stellt alles an, was noch keine Ablage hat.</summary>
///
/// <remarks>
/// Die zuletzt hinzugekommenen zuerst: was gerade in die Mediathek kam,
/// wird am ehesten als Nächstes geschaut.
/// </remarks>
public async Task NachtragenAsync(CancellationToken ct = default)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
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);
}
/// <summary>Ob diese Spur jetzt sofort ausgeliefert werden kann.</summary>
///
/// <remarks>
/// <b>Ohne zu warten und ohne anzustossen.</b> Für die HLS-Hauptliste:
/// was dort steht, muss ein Spieler in dem Augenblick abholen können, in
/// dem er es liest. AVPlayer nimmt ein „gleich" nicht an — eine Spur,
/// die beim Abruf noch nicht da ist, lässt ihn die ganze Wiedergabe
/// abbrechen, nicht bloss den Untertitel.
///
/// <b>Eine leere Datei zählt nicht.</b> Sie ist das Ergebnis „diese Spur
/// hat keinen Text"; sie anzubieten wäre ein Menüeintrag, der zu nichts
/// führt.
/// </remarks>
public bool Liegtvor(Guid dateiId, DateTimeOffset geaendert, int spur)
{
var ziel = Pfad(dateiId, geaendert, spur);
return File.Exists(ziel) && new FileInfo(ziel).Length > 0;
}
/// <summary>Holt eine Spur — und wartet dabei höchstens <paramref name="geduld"/>.</summary>
///
/// <remarks>
/// <b>Warum überhaupt gewartet wird.</b> 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.
/// </remarks>
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);
}
/// <summary>Eine Datei nach der anderen, bis die Reihe leer ist.</summary>
///
/// <remarks>
/// <b>Eine, nicht zehn.</b> 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.
/// </remarks>
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<FlaroContext>();
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);
}
/// <summary>Die Spurnummern, aus denen sich Text holen lässt.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
public static async Task<IReadOnlyList<int>> 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();
}
/// <summary>Bildbasierte Untertitel lassen sich nicht nach WebVTT wandeln.</summary>
public static bool IstBild(string codec) =>
codec is "hdmv_pgs_subtitle" or "pgssub" or "dvd_subtitle" or "dvdsub" or "xsub";
}