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>
305 lines
12 KiB
C#
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";
|
|
}
|