using System.Collections.Concurrent; using System.Diagnostics; using Flaro.Core.Delivery; using Flaro.Core.Library; using Flaro.Data; using Flaro.Media; using Microsoft.EntityFrameworkCore; namespace Flaro.Api; /// Eine laufende Wiedergabe mit ihrem ffmpeg dahinter. /// /// /// Ab welcher Sekunde des Films diese Sitzung beginnt. Die Zeitachse des /// Spielers fängt bei null an, nicht beim Film — wer ab Minute 30 /// einsteigt, dessen Spieler steht bei 0, während der Film bei 1800 steht. /// Ohne diesen Wert ginge jeder gemeldete Stand um eine halbe Stunde daneben. /// public sealed record Stromsitzung( Guid Kennung, Guid DateiId, Guid Benutzer, string Ordner, double Ab, int? Hoehe, int? Tonspur, Stromart Art, DateTimeOffset Begonnen) { /// Wann zuletzt ein Segment abgeholt wurde. /// /// /// Daran hängt das Aufräumen. Ein geschlossener Browser sagt /// niemandem Bescheid; bliebe die Sitzung stehen, liefe ffmpeg weiter und /// schriebe den ganzen Film auf den Kratzplatz. /// public DateTimeOffset Gesehen { get; set; } = Begonnen; public string? Fehler { get; set; } public bool Beendet { get; set; } } /// Verwaltet laufende Ströme: starten, bedienen, aufräumen. /// /// /// /// Neben , nicht an seiner Stelle. Der /// baut eine fertige Datei zum Mitnehmen — mit Länge, Bereichsanfragen und /// Fortsetzen nach einem Abbruch; das bleibt für die Apps richtig. Hier geht /// es um etwas anderes: der erste Ton soll nach Sekunden kommen, nicht nach /// Minuten. /// /// /// Eine Sitzung je Wiedergabe, nicht je Datei. Zwei Leute können /// denselben Film in verschiedenen Auflösungen und mit verschiedenen /// Tonspuren sehen. Das nach Dateikennung zu führen hiesse, dass der zweite /// bekommt, was der erste gewählt hat. /// /// public sealed class Streamkoordinator( IServiceScopeFactory scopes, FlaroPaths pfade, FfmpegOptions ffmpeg, Schluesselbilder schluesselbilder, TimeProvider clock, ILogger log) : IDisposable { private readonly ConcurrentDictionary _sitzungen = new(); private sealed record Eintrag(Stromsitzung Sitzung, Process Prozess, CancellationTokenSource Abbruch); /// Nach dieser Stille gilt eine Sitzung als verlassen. /// /// /// Grosszügiger als ein Segment lang. Wer pausiert, holt eine /// Weile nichts ab; ihn nach zwanzig Sekunden abzuwürgen wäre /// Sparsamkeit an der falschen Stelle. Zwei Minuten reichen für eine /// Pause und sind kurz genug, dass ein geschlossener Browser nicht /// stundenlang Platten füllt. /// public static readonly TimeSpan Geduld = TimeSpan.FromMinutes(2); /// Wie viele Ströme gleichzeitig laufen dürfen. /// /// /// Weil Umkodieren einen ganzen Rechner beschäftigt. Vier /// gleichzeitige 4K-Läufe bringen jedes NAS zum Stehen — und dann /// ruckelt nicht einer, sondern alle vier. /// private const int HoechstensGleichzeitig = 6; public int LaufendeAnzahl => _sitzungen.Count; public Stromsitzung? Sitzung(Guid kennung) => _sitzungen.GetValueOrDefault(kennung)?.Sitzung; /// Startet einen Strom und gibt die Sitzung zurück. /// /// Umkodieren, auch wenn das Profil etwas anderes sagt — der Spieler hat /// es mit dem ersten Plan probiert und ist gescheitert. /// public async Task StartenAsync( Guid dateiId, Guid benutzer, DeviceProfile profil, int? hoehe, int? tonspur, double ab, CancellationToken ct = default, bool erzwingen = false, double tonversatz = 0) { AbgelaufeneWegraeumen(); if (_sitzungen.Count >= HoechstensGleichzeitig) { throw new InvalidOperationException( $"Es laufen bereits {HoechstensGleichzeitig} Wiedergaben, die umgerechnet werden müssen. " + "Warte, bis eine davon zu Ende ist."); } using var bereich = scopes.CreateScope(); var db = bereich.ServiceProvider.GetRequiredService(); var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == dateiId, ct) ?? throw new InvalidOperationException("Diese Datei steht nicht mehr in der Mediathek."); if (!File.Exists(datei.Path)) throw new InvalidOperationException($"„{Path.GetFileName(datei.Path)}“ liegt nicht mehr da."); var spuren = await db.MediaStreams.AsNoTracking() .Where(s => s.MediaFileId == dateiId).OrderBy(s => s.Index) .ToListAsync(ct); var gelesen = spuren.Select(s => new ProbedStream( s.Index, s.Kind, s.Codec, s.Language, s.Title, s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height)).ToList(); var plan = Stromentscheid.Fuer(datei.Container, gelesen, profil, hoehe, erzwingen); var kennung = Guid.CreateVersion7(); var ordner = Path.Combine(pfade.Transcode, "stroeme", kennung.ToString("N")); Directory.CreateDirectory(ordner); // Die eingestellte Stufe aus demselben Bereich — ein Lesevorgang, und // er steht hier, weil nur hier feststeht, dass überhaupt kodiert wird. var stufe = await bereich.ServiceProvider .GetRequiredService().StufeAsync(ct); // **Beim Kopieren wird auf das Schlüsselbild gerundet.** // // Ein kopierter Bildstrom lässt sich nicht mitten in einer Bildgruppe // aufschneiden; ffmpeg beginnt beim Schlüsselbild davor, während der // Ton neu gerechnet wird und genau beim Wunsch anfängt. Gemessen an // einer Quelle mit Schlüsselbildern alle fünf Sekunden, Sprung auf // Sekunde 12: Bild ab 0, Ton erst ab 1,98 — zwei Sekunden stummes // Bild, und die Uhr des Spielers zwei Sekunden daneben. // // Beim Umkodieren entfällt das: dort wird das Bild neu gerechnet und // schneidet auf die Sekunde genau. Dann vorzurunden hiesse, ohne Not // ein Stück zurückzuspringen. // **Gesprungen wird auf den Wunsch, gemeldet wird das Schlüsselbild.** // // Beim Kopieren springt ffmpeg ungenau — siehe `HlsArgumente` — und // landet damit auf dem Schlüsselbild vor dem Wunsch. Bild und Ton // fangen dort gemeinsam an; das ist die Zusage, und sie hängt an // keiner Rechnung hier. // // Wo genau das war, sagt die Suche. Sie ist dafür da, dem Spieler // eine ehrliche Uhr zu geben — nicht dafür, die Ausrichtung // herzustellen. Vergreift sie sich, geht die Uhr falsch; Bild und Ton // bleiben zusammen. Umgekehrt wäre es ein schlechter Tausch: einen // Sprungpunkt auf die Millisekunde zu treffen, ist zu viel verlangt. var gewuenscht = Math.Max(0, ab); var uhr = System.Diagnostics.Stopwatch.StartNew(); var gemeldet = plan.BildKopieren ? await schluesselbilder.DavorAsync(datei.Path, gewuenscht, ct) : gewuenscht; var suche = uhr.ElapsedMilliseconds; var anfrage = new Stromanfrage( datei.Path, ordner, gelesen, plan, tonspur, gewuenscht, Hardware(), stufe, tonversatz); // **Die tatsächlich benutzte Spur, nicht die gewünschte.** Sonst // stünde im Menü bei keiner ein Haken, obwohl eine davon läuft — und // bei einer Spur, die es nicht mehr gibt, bei der falschen. var sitzung = new Stromsitzung( kennung, dateiId, benutzer, ordner, gemeldet, plan.Hoehe, HlsArgumente.Tonspur(gelesen, tonspur)?.Index, plan.Art, clock.GetUtcNow()); var prozess = Starten(anfrage, sitzung); var abbruch = new CancellationTokenSource(); _sitzungen[kennung] = new Eintrag(sitzung, prozess, abbruch); // **Mit Zeiten, und auch im Tagesprotokoll.** // // „Es dauert fünfzehn Sekunden" liess sich nicht beantworten: zwischen // dem Antippen und dem ersten Bild liegen die Schlüsselbildsuche, der // Start von ffmpeg und das erste Segment, und keine dieser Strecken // war irgendwo abzulesen. Eine Zeile je Wiedergabe ist kein Lärm — // sie ist genau das, wonach man im Protokoll sucht. var meldung = $"Wiedergabe: {plan.Art} von {Path.GetFileName(datei.Path)} ab {sitzung.Ab:0.#}s" + (plan.Hoehe is { } h ? $", auf {h}p" : "") + $" — Schlüsselbildsuche {suche} ms, Vorbereitung {uhr.ElapsedMilliseconds} ms."; log.LogInformation("{Meldung}", meldung); return sitzung; } /// Hält eine Sitzung am Leben — bei jedem abgeholten Segment. public void Lebenszeichen(Guid kennung) { if (_sitzungen.TryGetValue(kennung, out var eintrag)) eintrag.Sitzung.Gesehen = clock.GetUtcNow(); } /// Beendet eine Sitzung und räumt ihren Ordner weg. public void Beenden(Guid kennung) { if (!_sitzungen.TryRemove(kennung, out var eintrag)) return; Abraeumen(eintrag); } /// Beendet, was niemand mehr abholt. /// /// /// Beim Starten einer neuen Sitzung, nicht in einem eigenen Takt. /// Ein Hintergrunddienst für einen Handgriff, der ohnehin selten /// geschieht, wäre ein Faden mehr, der schiefgehen kann. /// public void AbgelaufeneWegraeumen() { var grenze = clock.GetUtcNow() - Geduld; foreach (var (kennung, eintrag) in _sitzungen) { if (eintrag.Sitzung.Gesehen > grenze) continue; if (!_sitzungen.TryRemove(kennung, out var weg)) continue; log.LogInformation("Strom {Kennung} wurde seit {Geduld} nicht abgeholt — beendet.", kennung, Geduld); Abraeumen(weg); } } /// Ob die Playlist schon etwas enthält, das sich abspielen lässt. /// /// /// Die Datei allein genügt nicht. ffmpeg legt sie an, bevor das /// erste Segment fertig ist; ein Spieler, der sie dann bekommt, sieht /// eine leere Liste und gibt auf. /// public static string? Playlist(string ordner) { var pfad = Path.Combine(ordner, HlsArgumente.Playlist); if (!File.Exists(pfad)) return null; try { // `ReadAllText` auf einer Datei, in die ffmpeg gerade schreibt: // deshalb ausdrücklich teilend geöffnet, sonst gäbe es hier je // nach Zeitpunkt eine Ausnahme statt einer Antwort. using var strom = new FileStream( pfad, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); using var leser = new StreamReader(strom); var text = leser.ReadToEnd(); // Ohne Segment ist die Liste wertlos — der Spieler sähe eine // leere Playlist und gäbe auf. return text.Contains(".m4s", StringComparison.Ordinal) ? text : null; } catch (IOException) { return null; } } /// Ob die Playlist schon etwas enthält, das sich abspielen lässt. public static bool PlaylistBereit(string ordner) => Playlist(ordner) is not null; /// Das Gerät, über das VAAPI läuft. private const string Vaapigeraet = "/dev/dri/renderD128"; /// Das Ergebnis der einmaligen Probe — null, solange keine lief. private bool? _vaapiLaeuft; /// Welche Beschleunigung benutzt wird. /// /// /// /// auto fragt ffmpeg, statt die Datei anzusehen. 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, /// aber dahinter steht keine VA-Anzeige. ffmpeg bricht dann schon an der /// globalen Option ab — No VA display found for device, Rückgabe /// 234 —, und für den Zuschauer sieht das aus wie ein kaputter Server. /// /// /// Einmal je Lauf. Die Probe kostet einen ffmpeg-Start; sie vor /// jeder Wiedergabe zu wiederholen wäre Aufwand für eine Antwort, die /// sich nicht ändert. Wechselt die Hardware, wechselt auch der /// Container. /// /// /// FLARO_HWACCEL=vaapi überspringt sie — wer es /// ausdrücklich verlangt, hat einen Grund, und ein Fehlschlag steht dann /// im Protokoll. /// /// private Beschleunigung Hardware() { var gewuenscht = Environment.GetEnvironmentVariable("FLARO_HWACCEL")?.Trim().ToLowerInvariant(); return gewuenscht switch { "none" or "software" => Beschleunigung.Software, "vaapi" => Beschleunigung.Vaapi, _ => (_vaapiLaeuft ??= VaapiLaeuft()) ? Beschleunigung.Vaapi : Beschleunigung.Software, }; } /// Lässt ffmpeg das Gerät einmal wirklich öffnen. private bool VaapiLaeuft() { if (!File.Exists(Vaapigeraet)) return false; try { var start = new ProcessStartInfo(ffmpeg.Ffmpeg) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; // Ein Bild aus dem Nichts, nirgendwohin. Es geht allein darum, ob // die Zeile vor dem ersten Bild durchgeht. foreach (var a in new[] { "-hide_banner", "-loglevel", "error", "-vaapi_device", Vaapigeraet, "-f", "lavfi", "-i", "nullsrc=s=64x64:d=0.1", "-frames:v", "1", "-f", "null", "-", }) { start.ArgumentList.Add(a); } using var prozess = Process.Start(start); if (prozess is null) return false; // **Mit Frist.** Ein hängender Prozess hielte sonst die erste // Wiedergabe nach dem Start auf. if (!prozess.WaitForExit(5000)) { try { prozess.Kill(entireProcessTree: true); } catch (InvalidOperationException) { } log.LogWarning("Die VAAPI-Probe antwortete nicht; es läuft in Software."); return false; } if (prozess.ExitCode == 0) return true; log.LogInformation( "VAAPI ist nicht benutzbar ({Code}); es läuft in Software. {Grund}", prozess.ExitCode, prozess.StandardError.ReadToEnd().Trim()); return false; } catch (Exception fehler) when (fehler is System.ComponentModel.Win32Exception or IOException) { log.LogWarning(fehler, "Die VAAPI-Probe liess sich nicht starten; es läuft in Software."); return false; } } private Process Starten(Stromanfrage anfrage, Stromsitzung sitzung) { var argumente = HlsArgumente.Fuer(anfrage); var start = new ProcessStartInfo(ffmpeg.Ffmpeg) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; foreach (var a in argumente) start.ArgumentList.Add(a); var prozess = Process.Start(start) ?? throw new InvalidOperationException($"{ffmpeg.Ffmpeg} liess sich nicht starten."); // **Der Fehlerkanal wird gelesen, nicht nur umgeleitet.** Läuft die // Röhre voll, bleibt ffmpeg beim Schreiben stehen — und der Strom // hört mitten im Film auf, ohne dass irgendwo etwas stünde. _ = Task.Run(async () => { var text = await prozess.StandardError.ReadToEndAsync(); await prozess.WaitForExitAsync(); sitzung.Beendet = true; if (prozess.ExitCode == 0) { // **Auch ein geglückter Lauf darf etwas zu sagen haben.** // Vorher wurde der Text weggeworfen, sobald der Rückgabewert 0 // war — und genau die Meldungen, die einen Tonversatz // ankündigen, sind Warnungen, keine Fehler. Ein Film, der // schief klingt, aber durchläuft, hinterliess damit nirgends // eine Spur. if (text.Trim() is { Length: > 0 } warnungen) { log.LogWarning("Strom {Kennung}: ffmpeg meldet {Warnungen}", sitzung.Kennung, warnungen); } return; } sitzung.Fehler = text.Trim(); log.LogError("Wiedergabe gescheitert: ffmpeg endete mit {Code}. {Fehler}", prozess.ExitCode, sitzung.Fehler); }); _ = Task.Run(() => prozess.StandardOutput.ReadToEndAsync()); return prozess; } private void Abraeumen(Eintrag eintrag) { try { if (!eintrag.Prozess.HasExited) eintrag.Prozess.Kill(entireProcessTree: true); } catch (Exception fehler) when (fehler is InvalidOperationException or NotSupportedException) { log.LogDebug(fehler, "ffmpeg zu {Kennung} liess sich nicht beenden.", eintrag.Sitzung.Kennung); } eintrag.Prozess.Dispose(); eintrag.Abbruch.Dispose(); try { if (Directory.Exists(eintrag.Sitzung.Ordner)) Directory.Delete(eintrag.Sitzung.Ordner, recursive: true); } catch (IOException fehler) { log.LogWarning(fehler, "Der Ordner zu Strom {Kennung} blieb liegen.", eintrag.Sitzung.Kennung); } } /// Der Ordner, in dem die Segmente liegen. public string Wurzel => Path.Combine(pfade.Transcode, "stroeme"); /// Räumt alles weg, was unter der Wurzel liegt. /// /// /// Beim Herunterfahren und beim Hochfahren. Die Ordnernamen sind /// Sitzungskennungen aus dem Arbeitsspeicher; nach einem Start gibt es /// keine einzige Sitzung, also ist jeder Ordner verwaist. Bisher geschah /// das nur beim geordneten Ende — nach einem harten Abbruch blieb alles /// liegen, und niemand kam je wieder darauf zurück. /// public long WurzelLeeren() { if (!Directory.Exists(Wurzel)) return 0; var frei = Belegung(Wurzel); try { Directory.Delete(Wurzel, recursive: true); } catch (IOException fehler) { log.LogDebug(fehler, "Der Stromordner {Pfad} liess sich nicht leeren.", Wurzel); return 0; } return frei; } /// Räumt Ordner weg, zu denen es keine Sitzung mehr gibt. /// /// /// Nicht bloss Symmetrie zum Herunterfahren. Scheitert das /// Löschen in Abraeumen an einem IOException, ist die /// Sitzung aus dem Verzeichnis heraus und der Ordner bleibt für immer /// liegen. Die Altersprüfung nimmt dabei den Fall heraus, dass ein /// zweiter Prozess denselben Kratzplatz benutzt: ein aktiver Ordner /// bekommt alle paar Sekunden ein Segment geschrieben. /// public long VerwaisteOrdnerWegraeumen(DateTimeOffset jetzt) { AbgelaufeneWegraeumen(); if (!Directory.Exists(Wurzel)) return 0; long frei = 0; foreach (var ordner in Directory.EnumerateDirectories(Wurzel)) { var name = Path.GetFileName(ordner); if (Guid.TryParse(name, out var kennung) && _sitzungen.ContainsKey(kennung)) continue; if (jetzt - new DateTimeOffset(Directory.GetLastWriteTimeUtc(ordner), TimeSpan.Zero) < Geduld) continue; var gross = Belegung(ordner); try { Directory.Delete(ordner, recursive: true); frei += gross; } catch (IOException fehler) { log.LogDebug(fehler, "Der verwaiste Ordner {Pfad} liess sich nicht löschen.", ordner); } } return frei; } /// Wie viele Bytes unter einem Ordner liegen. public static long Belegung(string ordner) { try { return Directory.Exists(ordner) ? Directory.EnumerateFiles(ordner, "*", SearchOption.AllDirectories) .Sum(d => { try { return new FileInfo(d).Length; } catch (IOException) { return 0; } }) : 0; } catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException) { return 0; } } /// Beim Herunterfahren bleibt nichts laufen und nichts liegen. public void Dispose() { foreach (var kennung in _sitzungen.Keys) Beenden(kennung); // **Auch, was ein Absturz hinterlassen hat.** Nach einem harten Ende // steht der Ordner noch da; ihn beim nächsten Herunterfahren zu // übersehen hiesse, dass der Kratzplatz nur wächst. WurzelLeeren(); } }