diff --git a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs
index 4f617d9..0e10876 100644
--- a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs
@@ -53,8 +53,7 @@ public static class StreamEndpoints
// warten; der Vorrat ist bis dahin längst gefüllt. Wer sie sofort
// einschaltet, wartet so lange wie vorher, bekommt aber eine
// Antwort statt eines Fehlers.
- vorrat.Anstossen(gewuenschte.Id, gewuenschte.Path, gewuenschte.ModifiedAt,
- await Textspuren(db, id, CancellationToken.None));
+ vorrat.Anstellen(gewuenschte.Id, vordraengeln: true);
var profil = Profil(anfrage);
var hoehe = anfrage.Height is { } gewuenscht
@@ -238,13 +237,12 @@ public static class StreamEndpoints
if (datei is null || !File.Exists(datei.Path))
return Einwand.Fehlt("Diese Datei liegt nicht mehr da.");
- var spuren = await Textspuren(db, id, ct);
+ var spuren = await Untertitelvorrat.TextspurenAsync(db, id, ct);
if (!spuren.Contains(spur))
return Einwand.Fehlt("Diese Spur enthält keinen Text, sondern Bilder.");
var (stand, text) = await vorrat.HolenAsync(
- datei.Id, datei.Path, datei.ModifiedAt, spuren, spur,
- TimeSpan.FromSeconds(20), ct);
+ datei.Id, datei.ModifiedAt, spur, TimeSpan.FromSeconds(20), ct);
return stand switch
{
@@ -308,7 +306,7 @@ public static class StreamEndpoints
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
.ToList(),
SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle)
- .Where(s => s.Codec is null || !IstBild(s.Codec))
+ .Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec))
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
.ToList());
}
@@ -360,7 +358,7 @@ public static class StreamEndpoints
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
.ToList(),
SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle)
- .Where(s => s.Codec is null || !IstBild(s.Codec))
+ .Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec))
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
.ToList());
}
@@ -372,26 +370,6 @@ public static class StreamEndpoints
/// PGS ist auf jedem Blu-ray-Rip die Regel — ein Eintrag im Menü, der zu
/// nichts führt, ist schlechter als kein Eintrag.
///
- private static bool IstBild(string codec) =>
- codec is "hdmv_pgs_subtitle" or "pgssub" or "dvd_subtitle" or "dvdsub" or "xsub";
-
- /// Die Spurnummern, aus denen sich Text holen lässt.
- ///
- ///
- /// Dieselbe Auswahl, die auch im Menü steht — sonst böte der Spieler eine
- /// Spur an, die der Vorrat gar nicht erst zieht.
- ///
- private static async Task> Textspuren(
- 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();
- }
/// Wie eine Spur im Menü heisst.
private static string Benennung(MediaStream spur)
diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs
index 0989bba..abeb87b 100644
--- a/server/src/Flaro.Api/Program.cs
+++ b/server/src/Flaro.Api/Program.cs
@@ -312,7 +312,19 @@ if (Directory.Exists(oberfläche))
var leben = app.Services.GetRequiredService();
leben.ApplicationStarted.Register(() =>
- tagebuch.Schreiben($"Server gestartet — {Buildkennung.Beides}"));
+ {
+ tagebuch.Schreiben($"Server gestartet — {Buildkennung.Beides}");
+
+ // **Die Untertitel holen, bevor jemand sie einschaltet.**
+ //
+ // Eine Spur aus einer Datei zu ziehen heisst, die Datei ganz zu
+ // lesen — bei einem Dreistünder auf einem NAS sind das Minuten.
+ // Das im Augenblick des Einschaltens zu tun hiess: zwei Minuten
+ // „wird geholt", und noch immer kein Text. Hier läuft es einmal je
+ // Datei durch, eine nach der anderen, während niemand wartet.
+ _ = app.Services.GetRequiredService()
+ .NachtragenAsync(leben.ApplicationStopping);
+ });
leben.ApplicationStopping.Register(() =>
tagebuch.Schreiben("Server fährt herunter."));
}
diff --git a/server/src/Flaro.Api/ScanCoordinator.cs b/server/src/Flaro.Api/ScanCoordinator.cs
index f564284..b79482f 100644
--- a/server/src/Flaro.Api/ScanCoordinator.cs
+++ b/server/src/Flaro.Api/ScanCoordinator.cs
@@ -152,6 +152,14 @@ public sealed class ScanCoordinator(
zusammengelegt.Filme, zusammengelegt.Serien, libraryId);
}
+ // **Untertitel gleich mitnehmen.** Sie aus einer Datei zu holen
+ // heisst, die Datei ganz zu lesen; das im Augenblick des
+ // Einschaltens zu tun heisst, den Zuschauer Minuten warten zu
+ // lassen. Der Vorrat arbeitet das im Hintergrund ab, eine Datei
+ // nach der anderen.
+ _ = bereich.ServiceProvider.GetRequiredService()
+ .NachtragenAsync(CancellationToken.None);
+
_zustand[libraryId] = _zustand[libraryId] with
{
Status = "finished",
diff --git a/server/src/Flaro.Api/Untertitelvorrat.cs b/server/src/Flaro.Api/Untertitelvorrat.cs
index 9fe517d..6dba6e8 100644
--- a/server/src/Flaro.Api/Untertitelvorrat.cs
+++ b/server/src/Flaro.Api/Untertitelvorrat.cs
@@ -1,9 +1,11 @@
-using System.Collections.Concurrent;
+using Flaro.Core.Library;
+using Flaro.Data;
using Flaro.Media;
+using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
-/// Herausgezogene Untertitel, abgelegt für den zweiten Abruf.
+/// Herausgezogene Untertitel — vorher geholt, nicht beim Einschalten.
///
///
///
@@ -11,24 +13,33 @@ namespace Flaro.Api;
/// 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.
-/// Bei einem kleinen Prüffilm fällt das nie auf; bei einem Dreistünder auf
-/// einem NAS reicht es nie.
///
///
-/// Und es wurde schlimmer, bevor es besser wurde. Seit die Adresse den
-/// Einstiegspunkt mitführt (?ab=), ändert sie sich bei jedem Sprung —
-/// der Browser konnte nichts mehr aus seinem eigenen Zwischenspeicher
-/// nehmen, und jeder Sprung stiess einen neuen Durchlauf an. Deshalb liegt
-/// hier die ungeschobene Spur; das Verschieben ist eine Zeilenersetzung und
-/// kostet nichts.
+/// 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.
///
///
-/// Warm, bevor jemand fragt. Angestossen wird beim Start des Stroms,
-/// nicht erst beim Öffnen des Menüs. Wer nach fünf Minuten Untertitel
-/// einschaltet, wartet dann gar nicht mehr.
+/// 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)
+public sealed class Untertitelvorrat(
+ FlaroPaths pfade,
+ UntertitelLeser leser,
+ IServiceScopeFactory scopes,
+ ILogger log)
{
/// Was ein Abruf vorfindet.
public enum Stand
@@ -39,38 +50,78 @@ public sealed class Untertitelvorrat(FlaroPaths pfade, UntertitelLeser leser)
/// Liegt da und ist leer — diese Spur hat keinen Text.
Leer,
- /// Wird gerade herausgezogen.
+ /// Wird gerade geholt oder wartet in der Reihe.
Laeuft,
}
- /// Ein Lauf je Datei, nicht je Spur — er holt ohnehin alle.
- private readonly ConcurrentDictionary _laeuft = new();
+ /// 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");
- /// Stösst das Herausziehen an, wenn etwas fehlt — und kehrt sofort zurück.
- public void Anstossen(Guid dateiId, string quelle, DateTimeOffset geaendert, IReadOnlyList spuren)
+ /// Stellt eine Datei an — vorn, wenn jemand auf sie wartet.
+ public void Anstellen(Guid dateiId, bool vordraengeln = false)
{
- if (spuren.Count == 0) return;
-
- var offen = spuren
- .Where(s => !File.Exists(Pfad(dateiId, geaendert, s)))
- .Select(s => new UntertitelLeser.Auftrag(s, Pfad(dateiId, geaendert, s)))
- .ToList();
-
- if (offen.Count == 0) return;
-
- // **Ohne das Abbruchzeichen des Abrufers.** Der Lauf gehört nicht dem
- // ersten, der gefragt hat: schaltet der sein Fenster zu, während die
- // Datei gelesen wird, wäre sonst alles umsonst und der nächste finge
- // von vorn an.
- _laeuft.GetOrAdd(dateiId, _ => Task.Run(async () =>
+ lock (_schloss)
{
- try { await leser.ZiehenAsync(quelle, offen, CancellationToken.None); }
- finally { _laeuft.TryRemove(dateiId, out Task? _); }
- }));
+ 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 .
@@ -79,35 +130,127 @@ public sealed class Untertitelvorrat(FlaroPaths pfade, UntertitelLeser leser)
/// 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. Zwanzig
- /// Sekunden decken den gewöhnlichen Fall ab; der seltene bekommt eine
- /// Antwort, mit der sich etwas anfangen lässt.
+ /// wartet, hängt hinter jedem Reverse Proxy ins Zeitlimit.
///
public async Task<(Stand Stand, string Text)> HolenAsync(
- Guid dateiId, string quelle, DateTimeOffset geaendert,
- IReadOnlyList spuren, int spur, TimeSpan geduld, CancellationToken ct)
+ 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);
- Anstossen(dateiId, quelle, geaendert, spuren);
+ 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)
{
- if (File.Exists(ziel))
+ Guid naechste;
+ lock (_schloss)
{
- var text = await File.ReadAllTextAsync(ziel, ct);
- return (text.Length == 0 ? Stand.Leer : Stand.Fertig, text);
+ if (_reihe.First is null)
+ {
+ _arbeitet = false;
+ Laufende = null;
+ return;
+ }
+
+ naechste = _reihe.First.Value;
+ _reihe.RemoveFirst();
+ Laufende = naechste;
}
- // Der Lauf ist durch und die Datei fehlt trotzdem: diese Spur
- // liess sich nicht herausziehen. Weiterzuwarten hiesse, auf
- // etwas zu warten, das niemand mehr tut.
- if (!_laeuft.ContainsKey(dateiId)) return (Stand.Leer, "");
+ try { await Ziehen(naechste); }
+ catch (Exception fehler)
+ {
+ log.LogWarning(fehler, "Untertitel der Datei {Kennung} liessen sich nicht holen.", naechste);
+ }
- if (DateTimeOffset.UtcNow >= bis) return (Stand.Laeuft, "");
-
- await Task.Delay(TimeSpan.FromMilliseconds(400), ct);
+ // **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";
}
diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts
index 07511e7..524d6fe 100644
--- a/web/app/src/spieler.ts
+++ b/web/app/src/spieler.ts
@@ -793,9 +793,12 @@ export function spieler(anfrage: Spieleranfrage): void {
.find(t => t.dataset.spur === String(spur));
if (!element) return;
- // Rund zwei Minuten lang nachfragen. Wer länger braucht, hat ein
- // Problem, das kein Warten löst — und bekommt es dann auch gesagt.
- for (let versuch = 0; versuch < 40; versuch++) {
+ // **Lange nachfragen, und zwar absichtlich.** Der Server holt die Spur
+ // normalerweise schon im Hintergrund, bevor jemand sie einschaltet;
+ // landet man trotzdem hier, läuft gerade das Lesen einer grossen Datei,
+ // und das dauert Minuten. Aufzugeben hiesse, kurz vor dem Ziel
+ // abzubrechen — zehn Minuten sind der Preis dafür, dass es dann klappt.
+ for (let versuch = 0; versuch < 200; versuch++) {
if (geschlossen || untertitel !== spur) return;
const stand = await untertitelHolen(anfrage.dateiId, spur, vorlauf);
@@ -816,12 +819,12 @@ export function spieler(anfrage: Spieleranfrage): void {
}
if (versuch === 0) {
- hinweisZeigen('Die Untertitel werden aus der Datei geholt — das dauert beim ersten Mal.');
+ hinweisZeigen('Diese Untertitel werden einmalig aus der Datei geholt. Bei einem grossen Film dauert das ein paar Minuten — danach sind sie sofort da.');
}
await new Promise(r => setTimeout(r, 3000));
}
- hinweisZeigen('Die Untertitel brauchen zu lange. Versuche es später noch einmal.', 'fehler');
+ hinweisZeigen('Die Untertitel brauchen ungewöhnlich lange. Schau später noch einmal — im Hintergrund wird weitergelesen.', 'fehler');
}
/* ---------- Fehler ---------- */