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;
}
}
}
}