diff --git a/server/src/Flaro.Api/PackageCoordinator.cs b/server/src/Flaro.Api/PackageCoordinator.cs
index ce91445..fd11912 100644
--- a/server/src/Flaro.Api/PackageCoordinator.cs
+++ b/server/src/Flaro.Api/PackageCoordinator.cs
@@ -59,8 +59,24 @@ public sealed class PackageCoordinator(
/// heisst das Ergebnis anders — und niemand bekommt die alte Fassung
/// ausgeliefert, bloss weil sie zufällig noch herumliegt.
///
+ /// Wie das Umpacken gebaut wurde — Teil des Namens.
+ ///
+ ///
+ /// Sonst bleibt ein Fehler liegen, der behoben ist. Der Name trug
+ /// bisher nur Kennung und Änderungszeitpunkt der Quelle. Ändert sich das
+ /// Umpacken selbst, passt beides weiterhin — und eine fehlerhaft
+ /// gebaute Datei wird weiter ausgeliefert, obwohl der Server es längst
+ /// besser könnte.
+ ///
+ /// 2: genau eine Vorgabe je Spurart. Davor landeten zwei als
+ /// Vorgabe markierte Tonspuren in getrennten Alternativgruppen und
+ /// liefen gleichzeitig.
+ ///
+ private const int Bauart = 2;
+
public string PathFor(Guid fileId, DateTimeOffset geändert) =>
- Path.Combine(pfade.Transcode, "packages", $"{fileId:N}-{geändert.ToUnixTimeMilliseconds()}.mp4");
+ Path.Combine(pfade.Transcode, "packages",
+ $"{fileId:N}-{geändert.ToUnixTimeMilliseconds()}-{Bauart}.mp4");
public bool IsReady(Guid fileId, DateTimeOffset geändert) => File.Exists(PathFor(fileId, geändert));
diff --git a/server/src/Flaro.Media/RemuxArguments.cs b/server/src/Flaro.Media/RemuxArguments.cs
index f1e1a7c..0cf464c 100644
--- a/server/src/Flaro.Media/RemuxArguments.cs
+++ b/server/src/Flaro.Media/RemuxArguments.cs
@@ -87,6 +87,30 @@ public static class RemuxArguments
argumente.Add(Bitrate(spur.Channels).ToString());
}
+ // **Genau eine Vorgabe je Art — sonst laufen alle Spuren zugleich.**
+ //
+ // Gemeldet als „auf der App höre ich einfach alle Audiospuren
+ // gleichzeitig", und so entsteht es: MP4 ordnet Spuren derselben Art
+ // einer `alternate_group` zu, und ein Abspieler nimmt daraus genau
+ // eine. ffmpeg leitet diese Gruppe aus der Markierung der Quelle ab.
+ // Sind dort zwei Tonspuren als Vorgabe markiert — bei Rips aus freier
+ // Wildbahn eher Regel als Ausnahme —, hält ffmpeg sie für
+ // Verschiedenes und legt sie in getrennte Gruppen.
+ //
+ // Gemessen an einer Quelle mit zwei markierten Tonspuren:
+ //
+ // ohne diese Zeilen Spur 2: an, Gruppe 1
+ // Spur 3: an, Gruppe 2 <- beide laufen
+ // mit diesen Zeilen Spur 2: an, Gruppe 1
+ // Spur 3: aus, Gruppe 1 <- eine zur Wahl
+ //
+ // Welche die Vorgabe wird: die, die es in der Quelle schon war. Gab
+ // es keine, die erste. Das ist keine Auswahl für den Zuschauer — er
+ // schaltet im Spieler um —, sondern nur die Zusage, dass es eine
+ // gibt und nicht zwei.
+ Vorgabe(argumente, "a", tonspuren);
+ Vorgabe(argumente, "s", genommen.Where(s => s.Kind == MediaStreamKind.Subtitle).ToList());
+
// Das Inhaltsverzeichnis nach vorn. Ohne das muss ein Abspieler erst
// ans Dateiende springen, bevor er irgendetwas zeigen kann — bei einer
// heruntergeladenen Datei ist das der Unterschied zwischen „läuft
@@ -98,6 +122,43 @@ public static class RemuxArguments
return argumente;
}
+ /// Markiert genau eine Spur einer Art als Vorgabe, alle anderen nicht.
+ ///
+ ///
+ /// Auch die Null gehört hingeschrieben. Es genügt nicht, die eine
+ /// zu markieren: ffmpeg übernimmt sonst die Markierung der Quelle für
+ /// alle übrigen, und genau die ist ja das Problem.
+ ///
+ /// Bei Untertiteln kann die Vorgabe fehlen, und das ist richtig
+ /// so — war in der Quelle keiner vorgesehen, soll auch keiner von selbst
+ /// erscheinen. Bei Ton gibt es dagegen immer eine; eine Datei ganz ohne
+ /// eingeschaltete Tonspur wäre stumm.
+ ///
+ /// -disposition ersetzt die ganze Markierung, sie ergänzt sie
+ /// nicht. „Erzwungen" muss deshalb ausdrücklich mitgeschrieben
+ /// werden, sonst verlöre eine erzwungene Untertitelspur genau das, was
+ /// sie ausmacht. Was nicht kennt — etwa
+ /// „für Hörgeschädigte" oder „Kommentar" —, geht dabei verloren; das ist
+ /// der Preis dafür, dass überhaupt etwas abspielbar herauskommt.
+ ///
+ private static void Vorgabe(List argumente, string art, IReadOnlyList spuren)
+ {
+ if (spuren.Count < 2) return;
+
+ var ausQuelle = spuren.ToList().FindIndex(s => s.IsDefault);
+ var gewaehlt = ausQuelle >= 0 ? ausQuelle : art == "a" ? 0 : -1;
+
+ for (var stelle = 0; stelle < spuren.Count; stelle++)
+ {
+ var teile = new List();
+ if (stelle == gewaehlt) teile.Add("default");
+ if (spuren[stelle].IsForced) teile.Add("forced");
+
+ argumente.Add($"-disposition:{art}:{stelle}");
+ argumente.Add(teile.Count > 0 ? string.Join('+', teile) : "0");
+ }
+ }
+
private static int Bitrate(int? kanäle) =>
Math.Min(HöchsteBitrate, Math.Max(1, kanäle ?? 2) * BitrateJeKanal);
}
diff --git a/server/tests/Flaro.Media.Tests/RemuxArgumentsTests.cs b/server/tests/Flaro.Media.Tests/RemuxArgumentsTests.cs
index 430fd69..c5fbede 100644
--- a/server/tests/Flaro.Media.Tests/RemuxArgumentsTests.cs
+++ b/server/tests/Flaro.Media.Tests/RemuxArgumentsTests.cs
@@ -118,4 +118,84 @@ public class RemuxArgumentsTests
Assert.That(argumente[argumente.IndexOf("-i") + 1], Is.EqualTo("/medien/film.mkv"));
Assert.That(argumente[^1], Is.EqualTo("/kratz/film.mp4"), "Das Ziel steht bei ffmpeg immer zuletzt");
}
+
+ /// Genau eine Tonspur ist die Vorgabe — sonst laufen alle zugleich.
+ ///
+ ///
+ /// Gemeldet als „ich höre einfach alle Audiospuren gleichzeitig".
+ /// MP4 ordnet Spuren derselben Art einer alternate_group zu, und
+ /// ein Abspieler nimmt daraus genau eine. ffmpeg leitet diese Gruppe aus
+ /// der Markierung der Quelle ab — sind dort zwei Tonspuren als Vorgabe
+ /// markiert, legt es sie in getrennte Gruppen, und beide laufen.
+ ///
+ [Test]
+ public void Nur_eine_Tonspur_bleibt_Vorgabe()
+ {
+ var zwei = new List
+ {
+ Bild(),
+ Ton(1) with { IsDefault = true },
+ Ton(2) with { IsDefault = true },
+ };
+
+ var zeile = AlsZeile(Bauen(zwei));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(zeile, Does.Contain("-disposition:a:0 default"));
+ Assert.That(zeile, Does.Contain("-disposition:a:1 0"),
+ "auch die Null gehört hingeschrieben: ffmpeg übernähme sonst die Markierung der Quelle");
+ });
+ }
+
+ /// Die Vorgabe der Quelle bleibt die Vorgabe.
+ [Test]
+ public void Die_schon_vorgegebene_Spur_bleibt_es()
+ {
+ var zeile = AlsZeile(Bauen([Bild(), Ton(1), Ton(2) with { IsDefault = true }]));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(zeile, Does.Contain("-disposition:a:0 0"));
+ Assert.That(zeile, Does.Contain("-disposition:a:1 default"));
+ });
+ }
+
+ /// „Erzwungen" überlebt die Behandlung.
+ ///
+ ///
+ /// -disposition ersetzt die ganze Markierung, statt sie zu
+ /// ergänzen. Wer hier nur „default" schreibt, nimmt einer erzwungenen
+ /// Untertitelspur genau das, was sie ausmacht.
+ ///
+ [Test]
+ public void Erzwungene_Untertitel_bleiben_erzwungen()
+ {
+ var zeile = AlsZeile(Bauen(
+ [Bild(), Ton(1), Text(2) with { IsForced = true }, Text(3)]));
+
+ Assert.That(zeile, Does.Contain("-disposition:s:0 forced"));
+ }
+
+ /// Ohne Vorgabe in der Quelle erscheint auch kein Untertitel von selbst.
+ [Test]
+ public void Untertitel_ohne_Vorgabe_bekommen_keine()
+ {
+ var zeile = AlsZeile(Bauen([Bild(), Ton(1), Text(2), Text(3)]));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(zeile, Does.Contain("-disposition:s:0 0"));
+ Assert.That(zeile, Does.Not.Contain("-disposition:s:0 default"));
+ });
+ }
+
+ /// Bei einer einzigen Spur gibt es nichts zu entscheiden.
+ [Test]
+ public void Eine_einzige_Tonspur_wird_nicht_angefasst()
+ {
+ var zeile = AlsZeile(Bauen([Bild(), Ton(1)]));
+
+ Assert.That(zeile, Does.Not.Contain("-disposition:a"));
+ }
}
diff --git a/server/tests/Flaro.Media.Tests/RemuxTests.cs b/server/tests/Flaro.Media.Tests/RemuxTests.cs
index 3a51241..9d33613 100644
--- a/server/tests/Flaro.Media.Tests/RemuxTests.cs
+++ b/server/tests/Flaro.Media.Tests/RemuxTests.cs
@@ -1,3 +1,5 @@
+using System.Buffers.Binary;
+using System.Text;
using Flaro.Core.Delivery;
using Flaro.Core.Library;
using Microsoft.Extensions.Logging.Abstractions;
@@ -185,4 +187,95 @@ public class RemuxTests
Assert.That(File.Exists(ziel), 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;
+ }
+ }
+ }
}
diff --git a/server/tests/Flaro.Media.Tests/Testdateien.cs b/server/tests/Flaro.Media.Tests/Testdateien.cs
index 430c792..52a78b0 100644
--- a/server/tests/Flaro.Media.Tests/Testdateien.cs
+++ b/server/tests/Flaro.Media.Tests/Testdateien.cs
@@ -50,6 +50,28 @@ public static class Testdateien
File.Delete(untertitel);
}
+ /// Zwei Tonspuren, und beide sind als Vorgabe markiert.
+ ///
+ ///
+ /// Keine Schikane, sondern der Normalfall. Wer eine Datei aus
+ /// mehreren Quellen zusammenmuxt, lässt die Markierung gern auf jeder
+ /// stehen. In Matroska stört das niemanden; in MP4 heisst es, dass ein
+ /// Abspieler beide Spuren für etwas Eigenes hält und gleichzeitig
+ /// abspielt.
+ ///
+ public static void MitZweiVorgabetonspuren(string ziel) => Ausführen("ffmpeg",
+ [
+ "-y", "-loglevel", "error",
+ "-f", "lavfi", "-i", "testsrc=duration=1:size=160x120:rate=10",
+ "-f", "lavfi", "-i", "sine=frequency=440:duration=1",
+ "-f", "lavfi", "-i", "sine=frequency=880:duration=1",
+ "-map", "0:v", "-map", "1:a", "-map", "2:a",
+ "-metadata:s:a:0", "language=deu", "-metadata:s:a:1", "language=eng",
+ "-disposition:a:0", "default", "-disposition:a:1", "default",
+ "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac",
+ ziel,
+ ]);
+
/// Eine schlichte MP4 mit einer Tonspur.
public static void Einfach(string ziel) => Ausführen("ffmpeg",
[