39,1 GB in FLARO_TRANSCODE, und das war kein Normalzustand, sondern ein
Leck: für jede Mitnahme baut der Server eine MP4 in Quellgrösse und löschte
sie nie. Die einzige Löschung, die es gab, räumte ältere Fassungen
derselben Datei weg. Stromordner verschwanden nur beim geordneten
Herunterfahren, Untertitel gar nicht.
**Zuerst: fertig heisst fertig.** ffmpeg schrieb direkt auf den Endnamen.
Nach einem harten Abbruch lag dort eine abgeschnittene Datei, `IsReady`
meldete „fertig", und sie ging so hinaus — und ein Aufräumer konnte
vollständig und abgeschnitten nicht unterscheiden. Gebaut wird jetzt unter
`.teil`, umbenannt erst nach Erfolg. Dabei muss das Format ausdrücklich
gesetzt werden: ffmpeg wählt den Muxer an der Endung und antwortet auf
`.teil` mit „Unable to choose an output format". Das hätte jedes Umpacken
zerlegt; die Prüfung hat es gefunden, bevor es jemand zu spüren bekam.
**Zwei Stufen für die Pakete.** Verwaiste gehen bedingungslos: unleserlicher
Name, fremde Bauart, kein Eintrag in der Mediathek, oder der Zeitstempel im
Namen passt nicht mehr. Der Rest fällt unter Obergrenze (20 GB) und Alter
(7 Tage), Ältestes zuerst.
Beides, und nicht eines: ein Alter allein löst den Fall nicht, denn die 39
GB waren gültige, aktuelle Dateien, jede für sich berechtigt. Eine
Obergrenze allein ist zu grob — ein Topf knapp darunter, in dem seit Wochen
nichts gebraucht wurde, besetzt ein Volume, das anderes braucht.
„Zuletzt gebraucht" ist die Änderungszeit der Paketdatei selbst, bei jeder
Auslieferung neu gestempelt. Die Zugriffszeit wäre das Naheliegende, nur
führt sie nicht jedes Dateisystem — mit `noatime` steht sie still, und der
Aufräumer hielte alles für uralt.
Ein laufender Download wird nie gelöscht: ein Benutzungszähler hält die
Datei fest, solange die Antwort läuft, dazu eine Schonfrist von 30 Minuten.
Der Zähler ist auch unter Linux nötig — der laufende Strom überlebt ein
Entfernen, die Fortsetzung einer Bereichsanfrage nicht, und der Client finge
von vorn an.
**Stromordner** werden beim Start bedingungslos geleert — nach einem Start
gibt es keine einzige Sitzung, also ist jeder Ordner verwaist. Im Betrieb
nur die ohne passende Sitzung, deren Änderungszeit älter ist als die
Geduld; das fängt auch den Fall, dass `Abraeumen` an einem IOException
scheitert und der Ordner für immer liegenbleibt.
**Untertitel** nur, wenn sie verwaist sind. Keine Obergrenze: eine .vtt ist
Kilobytes, und eine gültige wird wieder gebraucht — sie nach Platz zu
löschen hiesse, Minuten Lesearbeit gegen Megabytes zu tauschen. Eine leere
bleibt ausdrücklich liegen: sie ist das Ergebnis „diese Spur hat keinen
Text", und wer sie wegräumt, lässt bei jedem Start eine vierzig Gigabyte
grosse Datei neu durchlesen.
**Vier Anlässe, kein Dienst.** Start, vor jedem Umpacken, nach einem Scan,
auf Knopfdruck — wie alles Wiederkehrende in diesem Server. Einer zur Zeit;
wer nicht hineinkommt, geht weiter.
Gemessen am laufenden Server mit Attrappen:
vorher packages 17408 KB (4), untertitel 5 KB (3), 1 Stromordner
nachher 14 MB freigegeben, 4 Dateien
gültiges Paket blieb, verwaistes/veraltetes/Teilstück weg,
verwaiste .vtt weg, gültige blieben, Stromordner weg
Obergrenze 1 MB, Datei in der Schonfrist -> bleibt
Obergrenze 1 MB, Schonfrist vorbei -> 3072 KB freigegeben
In der Übersicht steht die Belegung je Unterordner mit Dateizahl und
daneben „Jetzt aufräumen"; die drei Zahlen stehen unter Wiedergabe, wo
Transcoder-Stufe und Schwellen schon stehen — der Kratzplatz ist, was die
Wiedergabe hinterlässt.
714 Tests grün, davon 36 neue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
314 lines
13 KiB
C#
314 lines
13 KiB
C#
using System.Buffers.Binary;
|
|
using System.Text;
|
|
using Flaro.Core.Delivery;
|
|
using Flaro.Core.Library;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace Flaro.Media.Tests;
|
|
|
|
/// <summary>Umpacken, von der echten Datei bis zum Ergebnis.</summary>
|
|
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<FfprobeRunner>.Instance);
|
|
_umpacker = new FfmpegRemuxer(optionen, NullLogger<FfmpegRemuxer>.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<double>? 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);
|
|
}
|
|
|
|
/// <summary>Der Beweis, auf dem das ganze Projekt ruht.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Zwei Tonspuren und ein Untertitel gehen hinein, und alles kommt in
|
|
/// der MP4 wieder heraus</b> — 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.
|
|
/// </remarks>
|
|
[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"));
|
|
}
|
|
|
|
/// <summary>Nur die untaugliche Spur wird gerechnet, die andere kopiert.</summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>Das Bild wird nie angefasst — nachgewiesen an der Bildgrösse und am Codec.</summary>
|
|
[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<double>();
|
|
var (ergebnis, _) = await Umpacken(quelle, new Progress<double>(w => { lock (gemeldet) gemeldet.Add(w); }));
|
|
|
|
Assert.That(ergebnis.Success, Is.True);
|
|
// Progress<T> 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");
|
|
}
|
|
}
|
|
|
|
/// <summary>Eine halbe Datei ist schlimmer als keine.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Bliebe sie liegen, hielte der nächste Aufruf sie für fertig und gäbe
|
|
/// einen abgeschnittenen Film heraus — der beim Abspielen mittendrin
|
|
/// abbricht.
|
|
/// </remarks>
|
|
[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<OperationCanceledException>(() => _umpacker.RunAsync(
|
|
new RemuxRequest(quelle, ziel, davor!.Streams,
|
|
DeliveryDecider.ForDownload(davor.Container, davor.Streams)),
|
|
ct: abbruch.Token));
|
|
|
|
Assert.That(File.Exists(ziel), Is.False);
|
|
}
|
|
|
|
/// <summary>Der Umpacker schreibt dorthin, wohin man ihn schickt.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Warum das eine eigene Prüfung ist.</b> Der Koordinator lässt jetzt
|
|
/// unter <c>.teil</c> 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.
|
|
/// </remarks>
|
|
[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);
|
|
});
|
|
}
|
|
|
|
/// <summary>Zwei Tonspuren stehen zur Wahl — sie laufen nicht gleichzeitig.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Der gemeldete Fehler, in einer Datei festgehalten.</b> „Auf der App
|
|
/// höre ich einfach alle Audiospuren gleichzeitig." So entsteht das: MP4
|
|
/// ordnet Spuren derselben Art einer <c>alternate_group</c> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Geprüft wird am Spurkopf, nicht an der Disposition.</b> Was ein
|
|
/// Abspieler tut, hängt an <c>alternate_group</c> und am Schalter
|
|
/// „enabled" im <c>tkhd</c>; 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
[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");
|
|
});
|
|
}
|
|
|
|
/// <summary>Ein Spurkopf, so weit ein Abspieler ihn liest.</summary>
|
|
private readonly record struct Spurkopf(bool Eingeschaltet, int Gruppe);
|
|
|
|
/// <summary>Liest die <c>tkhd</c>-Kästen einer MP4 der Reihe nach.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Nur so weit, wie diese Prüfung es braucht: Kästen haben eine Länge
|
|
/// und einen Namen, <c>moov</c> und <c>trak</c> enthalten weitere.
|
|
/// </remarks>
|
|
private static List<Spurkopf> Spurkoepfe(string datei)
|
|
{
|
|
var daten = File.ReadAllBytes(datei);
|
|
var koepfe = new List<Spurkopf>();
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|