Der Spieler schickte sein Profil aus `canPlayType` — und das ist eine Auskunft des `<video>`-Elements. Ein Strom läuft aber nicht durch das Element, sondern durch den MSE-Puffer, und die beiden sind sich nicht einig. Gemessen in Chromium: | | canPlayType | MediaSource.isTypeSupported | |---|---|---| | `audio/flac` | probably | **false** | | `audio/mp4; codecs="mp3"` | probably | **false** | Der Server glaubte dem Profil, packte die Datei pflichtschuldig um und liess die Spur unberührt — und der Browser stand vor etwas, das er nach eigener Auskunft können müsste. Heraus kam „Dieser Browser kann die Spuren dieser Datei nicht öffnen". Jetzt zählt ein Codec nur, wenn **beide** Auskünfte ihn bestätigen; welcher der beiden Wege es wird, entscheidet der Server erst danach. Und geprüft wird jeder Typ in der Form, in der er wirklich ankommt — `audio/mp4` und nicht `audio/flac`: genau dieser Unterschied entschied bei FLAC zwischen „geht" und „geht nicht". Gemessen: MP3 fällt dadurch aus dem Profil, der Server rechnet es um, statt es durchzureichen. **Und wenn doch etwas durchrutscht, wird gerechnet statt gemeldet.** Scheitert die Wiedergabe mit einem Codec- oder Dekodierfehler, baut der Spieler den Strom genau einmal neu auf — mit `transcode: true`, an derselben Stelle. Der Satz „über das Zahnrad lässt sich eine Fassung wählen, die er versteht" ist weg: wer das liest, weiss weder welche, noch warum der Server sie nicht gleich gegeben hat. Lief schon ein gerechneter Strom, unterbleibt der zweite Versuch — dann liegt es nicht am Codec, und dreissig Sekunden Warten auf dasselbe sind keine Hilfe. **Nicht geprüft:** dass der Rückfall im Betrieb auslöst. Jede Datei im Prüfbestand spielt; ein echtes Scheitern liess sich hier nicht herbeiführen. Gemessen ist der Serverteil (`remux` wird zu `transcode`) und das neue Profil. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
328 lines
13 KiB
C#
328 lines
13 KiB
C#
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;
|
|
|
|
/// <summary>Eine laufende Wiedergabe mit ihrem ffmpeg dahinter.</summary>
|
|
///
|
|
/// <param name="Ab">
|
|
/// Ab welcher Sekunde des Films diese Sitzung beginnt. <b>Die Zeitachse des
|
|
/// Spielers fängt bei null an</b>, 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.
|
|
/// </param>
|
|
public sealed record Stromsitzung(
|
|
Guid Kennung,
|
|
Guid DateiId,
|
|
Guid Benutzer,
|
|
string Ordner,
|
|
double Ab,
|
|
int? Hoehe,
|
|
int? Tonspur,
|
|
Stromart Art,
|
|
DateTimeOffset Begonnen)
|
|
{
|
|
/// <summary>Wann zuletzt ein Segment abgeholt wurde.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Daran hängt das Aufräumen.</b> Ein geschlossener Browser sagt
|
|
/// niemandem Bescheid; bliebe die Sitzung stehen, liefe ffmpeg weiter und
|
|
/// schriebe den ganzen Film auf den Kratzplatz.
|
|
/// </remarks>
|
|
public DateTimeOffset Gesehen { get; set; } = Begonnen;
|
|
|
|
public string? Fehler { get; set; }
|
|
public bool Beendet { get; set; }
|
|
}
|
|
|
|
/// <summary>Verwaltet laufende Ströme: starten, bedienen, aufräumen.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Neben <see cref="PackageCoordinator"/>, nicht an seiner Stelle.</b> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Eine Sitzung je Wiedergabe, nicht je Datei.</b> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class Streamkoordinator(
|
|
IServiceScopeFactory scopes,
|
|
FlaroPaths pfade,
|
|
FfmpegOptions ffmpeg,
|
|
TimeProvider clock,
|
|
ILogger<Streamkoordinator> log) : IDisposable
|
|
{
|
|
private readonly ConcurrentDictionary<Guid, Eintrag> _sitzungen = new();
|
|
|
|
private sealed record Eintrag(Stromsitzung Sitzung, Process Prozess, CancellationTokenSource Abbruch);
|
|
|
|
/// <summary>Nach dieser Stille gilt eine Sitzung als verlassen.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Grosszügiger als ein Segment lang.</b> 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.
|
|
/// </remarks>
|
|
public static readonly TimeSpan Geduld = TimeSpan.FromMinutes(2);
|
|
|
|
/// <summary>Wie viele Ströme gleichzeitig laufen dürfen.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Weil Umkodieren einen ganzen Rechner beschäftigt.</b> Vier
|
|
/// gleichzeitige 4K-Läufe bringen jedes NAS zum Stehen — und dann
|
|
/// ruckelt nicht einer, sondern alle vier.
|
|
/// </remarks>
|
|
private const int HoechstensGleichzeitig = 6;
|
|
|
|
public int LaufendeAnzahl => _sitzungen.Count;
|
|
|
|
public Stromsitzung? Sitzung(Guid kennung) => _sitzungen.GetValueOrDefault(kennung)?.Sitzung;
|
|
|
|
/// <summary>Startet einen Strom und gibt die Sitzung zurück.</summary>
|
|
/// <param name="erzwingen">
|
|
/// Umkodieren, auch wenn das Profil etwas anderes sagt — der Spieler hat
|
|
/// es mit dem ersten Plan probiert und ist gescheitert.
|
|
/// </param>
|
|
public async Task<Stromsitzung> StartenAsync(
|
|
Guid dateiId, Guid benutzer, DeviceProfile profil,
|
|
int? hoehe, int? tonspur, double ab, CancellationToken ct = default,
|
|
bool erzwingen = false)
|
|
{
|
|
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<FlaroContext>();
|
|
|
|
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<Wiedergabeeinstellungen>().StufeAsync(ct);
|
|
|
|
var anfrage = new Stromanfrage(
|
|
datei.Path, ordner, gelesen, plan, tonspur, Math.Max(0, ab), Hardware(), stufe);
|
|
|
|
// **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, Math.Max(0, ab),
|
|
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);
|
|
|
|
log.LogInformation(
|
|
"Strom {Kennung}: {Art} von {Datei} ab {Ab}s{Hoehe}.",
|
|
kennung, plan.Art, Path.GetFileName(datei.Path), sitzung.Ab,
|
|
plan.Hoehe is { } h ? $", auf {h}p" : "");
|
|
|
|
return sitzung;
|
|
}
|
|
|
|
/// <summary>Hält eine Sitzung am Leben — bei jedem abgeholten Segment.</summary>
|
|
public void Lebenszeichen(Guid kennung)
|
|
{
|
|
if (_sitzungen.TryGetValue(kennung, out var eintrag))
|
|
eintrag.Sitzung.Gesehen = clock.GetUtcNow();
|
|
}
|
|
|
|
/// <summary>Beendet eine Sitzung und räumt ihren Ordner weg.</summary>
|
|
public void Beenden(Guid kennung)
|
|
{
|
|
if (!_sitzungen.TryRemove(kennung, out var eintrag)) return;
|
|
Abraeumen(eintrag);
|
|
}
|
|
|
|
/// <summary>Beendet, was niemand mehr abholt.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Beim Starten einer neuen Sitzung, nicht in einem eigenen Takt.</b>
|
|
/// Ein Hintergrunddienst für einen Handgriff, der ohnehin selten
|
|
/// geschieht, wäre ein Faden mehr, der schiefgehen kann.
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Ob die Playlist schon etwas enthält, das sich abspielen lässt.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Die Datei allein genügt nicht.</b> ffmpeg legt sie an, bevor das
|
|
/// erste Segment fertig ist; ein Spieler, der sie dann bekommt, sieht
|
|
/// eine leere Liste und gibt auf.
|
|
/// </remarks>
|
|
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; }
|
|
}
|
|
|
|
/// <summary>Ob die Playlist schon etwas enthält, das sich abspielen lässt.</summary>
|
|
public static bool PlaylistBereit(string ordner) => Playlist(ordner) is not null;
|
|
|
|
/// <summary>Welche Beschleunigung benutzt wird.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b><c>auto</c> prüft, statt zu behaupten.</b> Liegt kein
|
|
/// <c>/dev/dri/renderD128</c> vor — auf einem Mac, in einem Container ohne
|
|
/// durchgereichtes Gerät —, läuft es in Software weiter. Langsamer, aber
|
|
/// es läuft; ein ffmpeg, das mit „No VA display found" abbricht, wäre für
|
|
/// den Zuschauer dasselbe wie ein kaputter Server.
|
|
/// </remarks>
|
|
private Beschleunigung Hardware()
|
|
{
|
|
var gewuenscht = Environment.GetEnvironmentVariable("FLARO_HWACCEL")?.Trim().ToLowerInvariant();
|
|
|
|
return gewuenscht switch
|
|
{
|
|
"none" or "software" => Beschleunigung.Software,
|
|
"vaapi" => Beschleunigung.Vaapi,
|
|
_ => File.Exists("/dev/dri/renderD128") ? Beschleunigung.Vaapi : Beschleunigung.Software,
|
|
};
|
|
}
|
|
|
|
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) return;
|
|
|
|
sitzung.Fehler = text.Trim();
|
|
log.LogWarning("Strom {Kennung}: ffmpeg endete mit {Code}: {Fehler}",
|
|
sitzung.Kennung, 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Beim Herunterfahren bleibt nichts laufen und nichts liegen.</summary>
|
|
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.
|
|
try
|
|
{
|
|
var wurzel = Path.Combine(pfade.Transcode, "stroeme");
|
|
if (Directory.Exists(wurzel)) Directory.Delete(wurzel, recursive: true);
|
|
}
|
|
catch (IOException) { }
|
|
}
|
|
}
|