using System.Buffers.Binary; using System.Text; using Flaro.Core.Delivery; using Flaro.Core.Library; using Microsoft.Extensions.Logging.Abstractions; namespace Flaro.Media.Tests; /// Umpacken, von der echten Datei bis zum Ergebnis. public class RemuxTests { private string _ordner = ""; private FfprobeRunner _ffprobe = null!; private FfmpegRemuxer _umpacker = null!; [SetUp] public void Anlegen() { if (!Testdateien.FfmpegVorhanden) Assert.Ignore("ffmpeg ist auf diesem Rechner nicht installiert."); _ordner = Directory.CreateDirectory( Path.Combine(Path.GetTempPath(), "flaro-umpacken-" + Guid.CreateVersion7().ToString("N"))).FullName; var optionen = new FfmpegOptions(Testdateien.Suchen("ffmpeg")!, Testdateien.Suchen("ffprobe")!); _ffprobe = new FfprobeRunner(optionen, NullLogger.Instance); _umpacker = new FfmpegRemuxer(optionen, NullLogger.Instance); } [TearDown] public void Aufraeumen() { try { if (_ordner.Length > 0) Directory.Delete(_ordner, recursive: true); } catch (IOException) { } } private async Task<(RemuxOutcome Ergebnis, ProbeResult? Danach)> Umpacken( string quelle, IProgress? fortschritt = null) { var davor = await _ffprobe.ProbeAsync(quelle); Assert.That(davor, Is.Not.Null, "Die Quelldatei liess sich nicht lesen"); var plan = DeliveryDecider.ForDownload(davor!.Container, davor.Streams); var ziel = Path.Combine(_ordner, Path.GetFileNameWithoutExtension(quelle) + ".mp4"); var ergebnis = await _umpacker.RunAsync( new RemuxRequest(quelle, ziel, davor.Streams, plan), davor.Duration, fortschritt); return (ergebnis, ergebnis.Success ? await _ffprobe.ProbeAsync(ziel) : null); } /// Der Beweis, auf dem das ganze Projekt ruht. /// /// /// Zwei Tonspuren und ein Untertitel gehen hinein, und alles kommt in /// der MP4 wieder heraus — samt Sprachkennung und Namen. Genau das /// kann Jellyfins Transcode-Endpunkt nicht: der bildet je Sitzung eine /// Tonspur ab, und beim Umpacken verliert er zusätzlich die /// Sprachkennungen. Dass hier beides erhalten bleibt, ist der Grund für /// diesen Server. /// [Test] public async Task Alle_Ton_und_Textspuren_ueberstehen_das_Umpacken() { var quelle = Path.Combine(_ordner, "zwei-tonspuren.mkv"); Testdateien.MitZweiTonspuren(quelle); var (ergebnis, danach) = await Umpacken(quelle); Assert.That(ergebnis.Success, Is.True, ergebnis.Error); Assert.That(danach!.Container, Is.EqualTo("mp4")); var ton = danach.Streams.Where(s => s.Kind == MediaStreamKind.Audio).ToList(); Assert.That(ton, Has.Count.EqualTo(2), "Beide Tonspuren müssen da sein"); Assert.That(ton.Select(t => t.Title), Is.EqualTo(new[] { "Deutsch", "Audiodeskription" }), "Die Namen unterscheiden die beiden — ohne sie wäre keine Auswahl möglich"); Assert.That(ton.Select(t => t.Language), Is.EqualTo(new[] { "deu", "deu" }), "Die Sprachkennung überlebt — Jellyfin verliert sie an dieser Stelle"); Assert.That(danach.Streams.Count(s => s.Kind == MediaStreamKind.Subtitle), Is.EqualTo(1)); } [Test] public async Task Der_Untertitel_liegt_danach_als_tx3g_vor() { var quelle = Path.Combine(_ordner, "mit-text.mkv"); Testdateien.MitZweiTonspuren(quelle); var (_, danach) = await Umpacken(quelle); var text = danach!.Streams.Single(s => s.Kind == MediaStreamKind.Subtitle); Assert.That(text.Codec, Is.EqualTo("mov_text"), "MP4 kennt nur diese eine Form"); Assert.That(text.Language, Is.EqualTo("deu")); } /// Nur die untaugliche Spur wird gerechnet, die andere kopiert. [Test] public async Task Eine_untaugliche_Tonspur_wird_gerechnet_die_taugliche_nicht() { var quelle = Path.Combine(_ordner, "gemischt.mkv"); Testdateien.MitUntauglichemTon(quelle); var davor = await _ffprobe.ProbeAsync(quelle); Assert.That(davor!.Streams.Select(s => s.Codec), Does.Contain("dts"), "Die Vorlage muss DTS enthalten"); var (ergebnis, danach) = await Umpacken(quelle); Assert.That(ergebnis.Success, Is.True, ergebnis.Error); var ton = danach!.Streams.Where(s => s.Kind == MediaStreamKind.Audio).ToList(); Assert.That(ton, Has.Count.EqualTo(2)); Assert.That(ton.Select(t => t.Codec), Is.EqualTo(new[] { "aac", "aac" }), "DTS wurde zu AAC, und die erste Spur blieb, was sie war"); Assert.That(ton.Select(t => t.Language), Is.EqualTo(new[] { "deu", "eng" }), "Auch die neu gerechnete Spur behält ihre Sprache"); } /// Das Bild wird nie angefasst — nachgewiesen an der Bildgrösse und am Codec. [Test] public async Task Das_Bild_bleibt_unberuehrt() { var quelle = Path.Combine(_ordner, "bild.mkv"); Testdateien.MitZweiTonspuren(quelle); var davor = await _ffprobe.ProbeAsync(quelle); var (_, danach) = await Umpacken(quelle); var vorher = davor!.Streams.Single(s => s.Kind == MediaStreamKind.Video); var nachher = danach!.Streams.Single(s => s.Kind == MediaStreamKind.Video); Assert.That(nachher.Codec, Is.EqualTo(vorher.Codec)); Assert.That((nachher.Width, nachher.Height), Is.EqualTo((vorher.Width, vorher.Height))); } [Test] public async Task Der_Fortschritt_wird_gemeldet_und_endet_bei_eins() { var quelle = Path.Combine(_ordner, "fortschritt.mkv"); Testdateien.MitZweiTonspuren(quelle); var gemeldet = new List(); var (ergebnis, _) = await Umpacken(quelle, new Progress(w => { lock (gemeldet) gemeldet.Add(w); })); Assert.That(ergebnis.Success, Is.True); // Progress meldet über den Synchronisierungskontext — kurz warten. await Task.Delay(200); lock (gemeldet) { Assert.That(gemeldet, Is.Not.Empty); Assert.That(gemeldet[^1], Is.EqualTo(1.0)); Assert.That(gemeldet, Is.Ordered, "Der Fortschritt darf nicht zurückspringen"); } } /// Eine halbe Datei ist schlimmer als keine. /// /// /// Bliebe sie liegen, hielte der nächste Aufruf sie für fertig und gäbe /// einen abgeschnittenen Film heraus — der beim Abspielen mittendrin /// abbricht. /// [Test] public async Task Nach_einem_Fehlschlag_liegt_keine_halbe_Datei_herum() { var quelle = Path.Combine(_ordner, "kaputt.mkv"); await File.WriteAllTextAsync(quelle, "das ist kein Film"); var ziel = Path.Combine(_ordner, "kaputt.mp4"); var ergebnis = await _umpacker.RunAsync(new RemuxRequest( quelle, ziel, [], new DeliveryPlan(DeliveryMethod.Remux, [], [], []))); Assert.That(ergebnis.Success, Is.False); Assert.That(ergebnis.Error, Is.Not.Null.And.Not.Empty); Assert.That(File.Exists(ziel), Is.False); } [Test] public async Task Ein_Abbruch_laesst_nichts_zurueck() { var quelle = Path.Combine(_ordner, "abbruch.mkv"); Testdateien.MitZweiTonspuren(quelle); var ziel = Path.Combine(_ordner, "abbruch.mp4"); var davor = await _ffprobe.ProbeAsync(quelle); using var abbruch = new CancellationTokenSource(); await abbruch.CancelAsync(); Assert.ThrowsAsync(() => _umpacker.RunAsync( new RemuxRequest(quelle, ziel, davor!.Streams, DeliveryDecider.ForDownload(davor.Container, davor.Streams)), ct: abbruch.Token)); Assert.That(File.Exists(ziel), Is.False); } /// Der Umpacker schreibt dorthin, wohin man ihn schickt. /// /// /// Warum das eine eigene Prüfung ist. Der Koordinator lässt jetzt /// unter .teil bauen und benennt erst nach Erfolg um. Vorher /// schrieb ffmpeg direkt auf den Endnamen — nach einem harten Abbruch lag /// dort eine abgeschnittene Datei, die als fertig galt und ausgeliefert /// wurde, und ein Aufräumer konnte vollständig und abgeschnitten nicht /// unterscheiden. Dass der Zielpfad wirklich durchgereicht wird und /// nicht irgendwo abgeleitet, hält diese Prüfung fest. /// [Test] public async Task Das_Ergebnis_landet_genau_am_angegebenen_Pfad() { var quelle = Path.Combine(_ordner, "pfadprobe.mkv"); Testdateien.MitZweiTonspuren(quelle); var davor = await _ffprobe.ProbeAsync(quelle); var plan = DeliveryDecider.ForDownload(davor!.Container, davor.Streams); var ziel = Path.Combine(_ordner, "ergebnis.mp4.teil"); var ergebnis = await _umpacker.RunAsync( new RemuxRequest(quelle, ziel, davor.Streams, plan), davor.Duration); Assert.Multiple(() => { Assert.That(ergebnis.Success, Is.True, ergebnis.Error); Assert.That(File.Exists(ziel), Is.True, "unter dem Teilnamen, nicht unter dem Endnamen"); Assert.That(File.Exists(Path.Combine(_ordner, "ergebnis.mp4")), Is.False); }); } /// Zwei Tonspuren stehen zur Wahl — sie laufen nicht gleichzeitig. /// /// /// /// Der gemeldete Fehler, in einer Datei festgehalten. „Auf der App /// höre ich einfach alle Audiospuren gleichzeitig." So entsteht das: MP4 /// ordnet Spuren derselben Art einer alternate_group zu, und ein /// Abspieler nimmt daraus genau eine. ffmpeg leitet die Gruppe aus der /// Markierung der Quelle ab — sind dort zwei Tonspuren als Vorgabe /// markiert, hält es sie für Verschiedenes und legt sie in getrennte /// Gruppen. Dann laufen beide. /// /// /// Geprüft wird am Spurkopf, nicht an der Disposition. Was ein /// Abspieler tut, hängt an alternate_group und am Schalter /// „enabled" im tkhd; ffprobe zeigt davon nur einen Teil. Die /// zwanzig Zeilen Leser unten sind der Preis dafür, dass diese Prüfung /// dasselbe liest wie das iPad. /// /// [Test] public async Task Zwei_Tonspuren_stehen_zur_Wahl_statt_zugleich_zu_laufen() { var quelle = Path.Combine(_ordner, "zweivorgaben.mkv"); Testdateien.MitZweiVorgabetonspuren(quelle); var (ergebnis, _) = await Umpacken(quelle); Assert.That(ergebnis.Success, Is.True, ergebnis.Error); var koepfe = Spurkoepfe(Path.Combine(_ordner, "zweivorgaben.mp4")); var ton = koepfe.Skip(1).ToList(); Assert.Multiple(() => { Assert.That(ton, Has.Count.EqualTo(2)); Assert.That(ton[0].Gruppe, Is.EqualTo(ton[1].Gruppe), "beide Tonspuren müssen in derselben Alternativgruppe stehen, sonst sind sie keine Alternativen"); Assert.That(ton.Count(t => t.Eingeschaltet), Is.EqualTo(1), "genau eine läuft; sind es zwei, hört man beide übereinander"); }); } /// Ein Spurkopf, so weit ein Abspieler ihn liest. private readonly record struct Spurkopf(bool Eingeschaltet, int Gruppe); /// Liest die tkhd-Kästen einer MP4 der Reihe nach. /// /// /// Nur so weit, wie diese Prüfung es braucht: Kästen haben eine Länge /// und einen Namen, moov und trak enthalten weitere. /// private static List Spurkoepfe(string datei) { var daten = File.ReadAllBytes(datei); var koepfe = new List(); Durchgehen(daten, 0, daten.Length); return koepfe; void Durchgehen(byte[] d, int von, int bis) { var p = von; while (p + 8 <= bis) { var groesse = (long)BinaryPrimitives.ReadUInt32BigEndian(d.AsSpan(p, 4)); var art = Encoding.ASCII.GetString(d, p + 4, 4); var kopf = 8; if (groesse == 1) { groesse = (long)BinaryPrimitives.ReadUInt64BigEndian(d.AsSpan(p + 8, 8)); kopf = 16; } if (groesse < kopf || p + groesse > bis) return; if (art is "moov" or "trak") Durchgehen(d, p + kopf, (int)(p + groesse)); if (art == "tkhd") { var fassung = d[p + kopf]; var schalter = (d[p + kopf + 1] << 16) | (d[p + kopf + 2] << 8) | d[p + kopf + 3]; // Nach den Schaltern: Zeiten und Kennung, dann Ebene und Gruppe. var stelle = p + kopf + 4 + (fassung == 0 ? 28 : 40); var gruppe = BinaryPrimitives.ReadInt16BigEndian(d.AsSpan(stelle + 2, 2)); koepfe.Add(new Spurkopf((schalter & 1) != 0, gruppe)); } p += (int)groesse; } } } }