**Mehrfachfassungen lassen sich abhaken.** Zwei Dateien an einem Film sind meist zwei Fassungen und völlig in Ordnung. In der Liste standen sie trotzdem für immer: sie rechnet jedes Mal frisch, und es gab keinen Zustand. Wer absichtlich zwei Fassungen hält, konnte die Gruppe nicht loswerden — und unter den gewollten ging das Ungewollte unter. Abgehakt wird eine Dateizahl, kein Zeitpunkt. Ein Zeitstempel müsste gegen `MediaFile.ModifiedAt` verglichen werden, und eine alte Datei, frisch hineinkopiert, behält ihre alte Zeit und käme nie wieder hoch. Die Anzahl ist genau die Frage: kommt eine dritte Fassung dazu, meldet sich die Gruppe von selbst zurück. Gerechnet wird sie auf dem Server — ein Client mit veralteter Liste hakte sonst eine Gruppe mit zwei Dateien ab, die inzwischen drei hat. „Ignorierte zeigen" führt zurück, wie bei den erledigten Meldungen; ohne das wäre das Abhaken unumkehrbar und niemand wüsste, was er weggeklickt hat. **Die Startseite wartete dreimal nacheinander.** Erst die Serverauskunft, dann Konto und Mediatheken, dann erst die Startseite — drei Rundreisen, bevor das erste Bild in Auftrag ging. Liegt eine Sitzung im Speicher, laufen die ersten beiden jetzt zugleich los. Nur dann: ohne Sitzung liefen `/auth/me` und `/libraries` vor der Antwort auf `needsSetup` in ein 401, und die Sitzungsprüfung kann daraufhin abmelden. Gemessen am laufenden Server: `users/me`, `system/info` und `libraries` starten alle bei 37 ms, die Startseitenabfragen folgen bei 73 ms. Vorher lagen drei Runden dazwischen. **Sieben Hintergrundbilder auf einmal waren sechs zu viel.** Jede Folie des Aufmachers bekam sofort ihr `w1280`-Bild; sichtbar ist immer nur eine. Jetzt lädt die sichtbare sofort und der Rest in der Leerlaufzeit — und wer weiterschaltet, bevor das lief, bekommt sein Bild dann. **Die Schrift kam von Google.** `display=swap` rettete den Text, nicht den ersten Aufbau: ein Stylesheet blockiert das Zeichnen, und ein Heimserver ohne Internet oder mit träger Namensauflösung wartete davor. Inter liegt jetzt als zwei Dateien von zusammen sechzig Kilobyte im Projekt, variabel für alle Schnitte, mit `unicode-range` getrennt nach latin und latin-ext. Gemessen: null Anfragen an fonts.googleapis.com. Lizenztext liegt dabei. **Und mein Untertitelvorrat bremste mit.** Er startete sofort beim Hochfahren und las die ganze Mediathek — von derselben Platte, von der die Startseite ihre Bilder holt. Jetzt beginnt er eine Minute später und stellt zurück, solange eine Wiedergabe läuft. Die Datei bleibt dabei vorn in der Reihe; verworfen wird nichts. 678 Tests grün, davon zwei neue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
286 lines
11 KiB
C#
286 lines
11 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>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";
|
|
}
|