„Scratch disk" wörtlich zu übersetzen ist derselbe Fehler wie „Wachhund"
für Watchdog und „Keks" für Cookie. Und „Marke" ist kein Fachbegriff für
das, was gemeint ist: Jellyfin nennt diese Abschnitte Media Segments, und
das ist auch das Wort, in dem sich darüber reden lässt.
Kratzplatz* → Transcode* (was es ist, steht in FLARO_TRANSCODE)
Marke, Marken → Segment, Segmente
Markenerkennung → Segmenterkennung
Markenart/-suche → Segmentart/-suche
Tonabdruck → Fingerprint (Chromaprint nennt es so)
Tonabgleich → Fingerprintabgleich
Schlüsselbild → Keyframe
Bildabtaster → Frameabtaster
Gewöhnliches Deutsch bleibt gewöhnliches Deutsch: Wartung, Freigabe,
Meldung, Abspann, Vorspann. Das war nie das Problem.
**Was sich nicht ändert, und zwar mit Absicht.** Die drei
Einstellungsschlüssel `wartung.kratzplatz.paket.*` bleiben, wie sie sind —
sie stehen in der Datenbank, und ein neuer Name hiesse, dass die Werte
des Nutzers stillschweigend auf die Vorgabe zurückfallen. Dasselbe für
die Endpunktpfade `/marken`: sie sind nicht sichtbar, und ein Umbenennen
wäre Diff ohne Gewinn.
**Die Migration ist von Hand geschrieben.** `dotnet ef` hat ein Gerüst
erzeugt, das beide Tabellen löscht und neu anlegt — es sieht eine
verschwundene und eine neue Tabelle, nicht eine umbenannte. Damit wäre
jedes erkannte Segment und jeder Vermerk „hier wurde schon gesucht" weg
gewesen. `RenameTable` und `RenameIndex` tun dasselbe, ohne eine Zeile
anzufassen; nachgemessen an einer echten SQLite-Datei: Zeilen vorher 1
und 1, nachher 1 und 1, Inhalt unverändert.
Nachgezogen: in `bilder.ts` und `main.ts` heisst „marke" ein Häkchen und
das Logo — die blieben unberührt. Und „Segment" ist sächlich, was eine
Blindersetzung nicht weiss: „eine Segment" stand danach an vierzehn
Stellen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
342 lines
13 KiB
C#
342 lines
13 KiB
C#
using System.Diagnostics;
|
|
using Flaro.Core.Delivery;
|
|
using Flaro.Core.Library;
|
|
|
|
namespace Flaro.Media.Tests;
|
|
|
|
/// <summary>Dieselbe Zeile, diesmal wirklich ausgeführt.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Weil die reine Prüfung nur die Form zeigt.</b> Sie sagt, dass
|
|
/// <c>-hls_segment_type fmp4</c> in der Zeile steht — nicht, dass ffmpeg die
|
|
/// Kombination annimmt. Genau dort sitzen die teuren Fehler: eine Option, die
|
|
/// für sich richtig aussieht und in Verbindung mit einer anderen zum Abbruch
|
|
/// führt.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Übersprungen, wo kein ffmpeg liegt.</b> Eine Prüfung, die auf einem
|
|
/// Rechner ohne Werkzeug rot wird, sagt nichts über den Code.
|
|
/// </para>
|
|
/// </remarks>
|
|
public class HlsLaufTests
|
|
{
|
|
private string _ordner = "";
|
|
|
|
[SetUp]
|
|
public void Anlegen()
|
|
{
|
|
if (!Testdateien.FfmpegVorhanden) Assert.Ignore("Ohne ffmpeg nicht prüfbar.");
|
|
_ordner = Directory.CreateDirectory(
|
|
Path.Combine(Path.GetTempPath(), "flaro-hls-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
|
}
|
|
|
|
[TearDown]
|
|
public void Aufraeumen()
|
|
{
|
|
try { Directory.Delete(_ordner, recursive: true); } catch (IOException) { }
|
|
}
|
|
|
|
private static readonly DeviceProfile Browser = new(
|
|
Containers: new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "mp4" },
|
|
VideoCodecs: new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "h264", "avc1" },
|
|
AudioCodecs: new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "aac", "mp3" });
|
|
|
|
/// <summary>Führt die gebaute Zeile aus und gibt den Zielordner zurück.</summary>
|
|
private string Laufen(
|
|
string quelle, int? hoehe = null, int? tonspur = null, double ab = 0, double tonversatz = 0)
|
|
{
|
|
var spuren = Spuren(quelle);
|
|
var ziel = Directory.CreateDirectory(Path.Combine(_ordner, "aus" + Guid.NewGuid().ToString("N")[..6])).FullName;
|
|
|
|
var plan = Stromentscheid.Fuer(Path.GetExtension(quelle).TrimStart('.'), spuren, Browser, hoehe);
|
|
var argumente = HlsArgumente.Fuer(
|
|
new Stromanfrage(quelle, ziel, spuren, plan, tonspur, ab, Tonversatz: tonversatz));
|
|
|
|
var start = new ProcessStartInfo(Testdateien.Suchen("ffmpeg")!)
|
|
{
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false,
|
|
};
|
|
foreach (var a in argumente) start.ArgumentList.Add(a);
|
|
|
|
using var prozess = Process.Start(start)!;
|
|
var fehler = prozess.StandardError.ReadToEnd();
|
|
prozess.WaitForExit();
|
|
|
|
Assert.That(prozess.ExitCode, Is.Zero,
|
|
$"ffmpeg lehnte die gebaute Zeile ab:\n{string.Join(' ', argumente)}\n\n{fehler}");
|
|
return ziel;
|
|
}
|
|
|
|
/// <summary>Was wirklich in der Datei steht — nicht, was wir annehmen.</summary>
|
|
private static List<ProbedStream> Spuren(string datei)
|
|
{
|
|
var start = new ProcessStartInfo(Testdateien.Suchen("ffprobe")!)
|
|
{
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false,
|
|
};
|
|
foreach (var a in new[]
|
|
{
|
|
"-v", "error", "-show_entries", "stream=index,codec_type,codec_name,height",
|
|
"-of", "csv=p=0", datei,
|
|
})
|
|
{
|
|
start.ArgumentList.Add(a);
|
|
}
|
|
|
|
using var prozess = Process.Start(start)!;
|
|
var ausgabe = prozess.StandardOutput.ReadToEnd();
|
|
prozess.WaitForExit();
|
|
|
|
var spuren = new List<ProbedStream>();
|
|
foreach (var zeile in ausgabe.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
|
{
|
|
var teile = zeile.Trim().Split(',');
|
|
if (teile.Length < 3) continue;
|
|
MediaStreamKind? art = teile[2] switch
|
|
{
|
|
"video" => MediaStreamKind.Video,
|
|
"audio" => MediaStreamKind.Audio,
|
|
"subtitle" => MediaStreamKind.Subtitle,
|
|
// Anhänge und Datenströme sind hier nichts, womit sich rechnen
|
|
// liesse — sie zu einer Art zu erklären wäre eine Erfindung.
|
|
_ => null,
|
|
};
|
|
if (art is null) continue;
|
|
|
|
int? hoehe = teile.Length > 3 && int.TryParse(teile[3], out var h) ? h : null;
|
|
spuren.Add(new ProbedStream(
|
|
int.Parse(teile[0]), art.Value, teile[1], null, null, false, false, null, null, hoehe));
|
|
}
|
|
return spuren;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
|
|
/// <summary>Eine MKV wird umgepackt, und es entstehen abspielbare Segmente.</summary>
|
|
[Test]
|
|
public void Eine_mkv_wird_zu_Playlist_und_Segmenten()
|
|
{
|
|
var quelle = Path.Combine(_ordner, "film.mkv");
|
|
Testdateien.MitZweiTonspuren(quelle);
|
|
|
|
var ziel = Laufen(quelle);
|
|
|
|
var playlist = Path.Combine(ziel, "index.m3u8");
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(File.Exists(playlist), "die Liste muss da sein");
|
|
Assert.That(File.Exists(Path.Combine(ziel, "init.mp4")),
|
|
"fMP4 braucht ein Anfangsstück, sonst kann kein Spieler einsteigen");
|
|
Assert.That(Directory.GetFiles(ziel, "*.m4s"), Is.Not.Empty);
|
|
});
|
|
|
|
var text = File.ReadAllText(playlist);
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(text, Does.StartWith("#EXTM3U"));
|
|
Assert.That(text, Does.Contain("#EXT-X-MAP:URI=\"init.mp4\""));
|
|
});
|
|
}
|
|
|
|
/// <summary>Was herauskommt, ist H.264 und AAC — und nichts anderes.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Das ist der eigentliche Punkt.</b> Die Quelle hat eine Tonspur, die
|
|
/// kein Browser kennt; wäre sie kopiert worden, bliebe der Film stumm —
|
|
/// und genau so war es vorher.
|
|
/// </remarks>
|
|
[Test]
|
|
public void Aus_untauglichem_Ton_wird_AAC()
|
|
{
|
|
var quelle = Path.Combine(_ordner, "dts.mkv");
|
|
Testdateien.MitUntauglichemTon(quelle);
|
|
|
|
var ziel = Laufen(quelle, tonspur: 2); // die DTS-Spur ausdrücklich wählen
|
|
var codecs = Spuren(Path.Combine(ziel, "index.m3u8")).Select(s => s.Codec).ToList();
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(codecs, Does.Contain("h264"));
|
|
Assert.That(codecs, Does.Contain("aac"));
|
|
Assert.That(codecs, Does.Not.Contain("dts"));
|
|
});
|
|
}
|
|
|
|
/// <summary>Herunterskalieren ändert die Bildhöhe wirklich.</summary>
|
|
[Test]
|
|
public void Herunterskalieren_aendert_die_Hoehe()
|
|
{
|
|
var quelle = Path.Combine(_ordner, "gross.mkv");
|
|
Gross(quelle);
|
|
|
|
var ziel = Laufen(quelle, hoehe: 120);
|
|
var bild = Spuren(Path.Combine(ziel, "index.m3u8"))
|
|
.First(s => s.Kind == MediaStreamKind.Video);
|
|
|
|
Assert.That(bild.Height, Is.EqualTo(120));
|
|
}
|
|
|
|
/// <summary>Ein Sprung mitten hinein liefert ebenfalls Segmente.</summary>
|
|
[Test]
|
|
public void Ein_Sprungpunkt_liefert_den_Rest_des_Films()
|
|
{
|
|
var quelle = Path.Combine(_ordner, "lang.mkv");
|
|
Lang(quelle);
|
|
|
|
var ziel = Laufen(quelle, ab: 4);
|
|
|
|
Assert.That(Directory.GetFiles(ziel, "*.m4s"), Is.Not.Empty);
|
|
}
|
|
|
|
/// <summary>Bild und Ton fangen zusammen an — bei jedem Sprungwert.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Das ist der Test, der gefehlt hat.</b> Die Quelle hat genau ein
|
|
/// Keyframe, bei null; jeder Sprung landet also mitten in der
|
|
/// Bildgruppe — der schlimmste Fall. Genau gesprungen schnitte ffmpeg den
|
|
/// Ton auf die Sekunde, das kopierte Bild aber nicht, und dazwischen läge
|
|
/// Bild ohne Ton.
|
|
/// </para>
|
|
/// <para>
|
|
/// Zuerst hatte ich dafür den Sprung auf das Keyframe gerundet. Das
|
|
/// war zu scharf geschliffen: eine Millisekunde zu klein, und ffmpeg nimmt
|
|
/// das Keyframe davor — gemessen wurden aus 0,004 s Abstand dann
|
|
/// 4,98. `-noaccurate_seek` braucht diese Genauigkeit nicht.
|
|
/// </para>
|
|
/// </remarks>
|
|
[TestCase(0.0)]
|
|
[TestCase(1.5)]
|
|
[TestCase(3.2)]
|
|
[TestCase(4.0)]
|
|
[TestCase(6.75)]
|
|
public void Bild_und_Ton_fangen_bei_jedem_Sprung_zusammen_an(double ab)
|
|
{
|
|
var quelle = Path.Combine(_ordner, "lang.mkv");
|
|
Lang(quelle);
|
|
|
|
var (bild, ton) = Anfang(Laufen(quelle, ab: ab));
|
|
|
|
Assert.That(Math.Abs(bild - ton), Is.LessThan(0.2),
|
|
$"Sprung auf {ab}: Bild beginnt bei {bild}, Ton bei {ton} — das hört man.");
|
|
}
|
|
|
|
/// <summary>Bild und Ton fangen an derselben Stelle an.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Die Quelle hat genau ein Keyframe, bei null.</b> Ein Sprung auf
|
|
/// Sekunde 4 ist damit der schlimmste Fall: das kopierte Bild muss ganz
|
|
/// vorn anfangen, der neu gerechnete Ton könnte bei 4 anfangen — vier
|
|
/// Sekunden stummes Bild, und eine Uhr, die um vier Sekunden danebenliegt.
|
|
/// </para>
|
|
/// <para>
|
|
/// Gemessen an einer Quelle mit Keyframesn alle fünf Sekunden und
|
|
/// einem Sprung auf Sekunde 12 war es genau so: Bild ab 0, Ton ab 1,98.
|
|
/// Deshalb rundet der Koordinator den Sprung auf das Keyframe —
|
|
/// hier wird geprüft, dass die Zeile das auch verträgt.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Test]
|
|
public void Bild_und_Ton_fangen_zusammen_an()
|
|
{
|
|
var quelle = Path.Combine(_ordner, "lang.mkv");
|
|
Lang(quelle);
|
|
|
|
// Auf dem Keyframe, so wie der Koordinator es rundet.
|
|
var (bild, ton) = Anfang(Laufen(quelle, ab: 0));
|
|
|
|
Assert.That(Math.Abs(bild - ton), Is.LessThan(0.2),
|
|
$"Bild beginnt bei {bild}, Ton bei {ton} — das hört man.");
|
|
}
|
|
|
|
/// <summary>Der Tonversatz verschiebt den Ton, nicht das Bild.</summary>
|
|
[Test]
|
|
public void Ein_Tonversatz_verschiebt_nur_den_Ton()
|
|
{
|
|
var quelle = Path.Combine(_ordner, "lang.mkv");
|
|
Lang(quelle);
|
|
|
|
var (bildOhne, tonOhne) = Anfang(Laufen(quelle));
|
|
var (bildMit, tonMit) = Anfang(Laufen(quelle, tonversatz: 0.5));
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(Math.Abs(bildMit - bildOhne), Is.LessThan(0.1),
|
|
"Das Bild darf sich dabei nicht bewegen.");
|
|
Assert.That(tonMit - tonOhne, Is.EqualTo(0.5).Within(0.1),
|
|
"Eine halbe Sekunde später heisst eine halbe Sekunde später.");
|
|
});
|
|
}
|
|
|
|
private static void Gross(string ziel) => Werkzeug(
|
|
[
|
|
"-y", "-loglevel", "error",
|
|
"-f", "lavfi", "-i", "testsrc=duration=1:size=640x480:rate=10",
|
|
"-f", "lavfi", "-i", "sine=frequency=440:duration=1",
|
|
"-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac",
|
|
ziel,
|
|
]);
|
|
|
|
/// <summary>Wo Bild und Ton im Ergebnis anfangen — in Sekunden.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Das ist die Prüfung, die gefehlt hat.</b> Der Sprungtest daneben sah
|
|
/// nur nach, ob überhaupt Segmente entstehen — und lief dabei genau durch
|
|
/// den Fall, der schiefgehen kann, ohne hinzusehen.
|
|
/// </remarks>
|
|
private static (double Bild, double Ton) Anfang(string ziel)
|
|
{
|
|
double Erste(string spur)
|
|
{
|
|
var start = new ProcessStartInfo(Testdateien.Suchen("ffprobe")!)
|
|
{
|
|
ArgumentList =
|
|
{
|
|
"-v", "error", "-select_streams", spur,
|
|
"-show_entries", "packet=pts_time", "-of", "csv=p=0",
|
|
Path.Combine(ziel, HlsArgumente.Playlist),
|
|
},
|
|
RedirectStandardOutput = true,
|
|
UseShellExecute = false,
|
|
};
|
|
using var prozess = Process.Start(start)!;
|
|
var text = prozess.StandardOutput.ReadToEnd();
|
|
prozess.WaitForExit();
|
|
|
|
var erste = text.Split('\n', StringSplitOptions.RemoveEmptyEntries)[0].TrimEnd(',', '\r');
|
|
return double.Parse(erste, System.Globalization.CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
return (Erste("v"), Erste("a"));
|
|
}
|
|
|
|
private static void Lang(string ziel) => Werkzeug(
|
|
[
|
|
"-y", "-loglevel", "error",
|
|
"-f", "lavfi", "-i", "testsrc=duration=8:size=160x120:rate=10",
|
|
"-f", "lavfi", "-i", "sine=frequency=440:duration=8",
|
|
"-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac",
|
|
ziel,
|
|
]);
|
|
|
|
private static void Werkzeug(string[] argumente)
|
|
{
|
|
var start = new ProcessStartInfo(Testdateien.Suchen("ffmpeg")!)
|
|
{
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false,
|
|
};
|
|
foreach (var a in argumente) start.ArgumentList.Add(a);
|
|
|
|
using var prozess = Process.Start(start)!;
|
|
var fehler = prozess.StandardError.ReadToEnd();
|
|
prozess.WaitForExit();
|
|
if (prozess.ExitCode != 0) throw new InvalidOperationException(fehler);
|
|
}
|
|
}
|