diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs
index a3168ad..6251411 100644
--- a/server/src/Flaro.Api/Endpoints/Contracts.cs
+++ b/server/src/Flaro.Api/Endpoints/Contracts.cs
@@ -110,7 +110,17 @@ public sealed record MetadataProgress(
/// Eine Spur in einer Datei.
public sealed record MediaStreamResponse(
int Index, string Kind, string? Codec, string? Language, string? Title,
- bool IsDefault, bool IsForced, int? Channels, int? Width, int? Height);
+ bool IsDefault, bool IsForced, int? Channels, int? Width, int? Height,
+ /// Die Zeile, wie sie angezeigt werden soll.
+ ///
+ ///
+ /// Vom Server formuliert, nicht vom Client. „DEU EAC3 6.0" wird
+ /// „Deutsch · Dolby Digital Plus · 5.1" — und die Apps brauchen denselben
+ /// Satz. Zweimal formuliert driftet er auseinander, und dann heisst
+ /// dieselbe Tonspur am Fernseher anders als im Browser. Die Rohwerte
+ /// stehen weiterhin daneben; wer sie braucht, hat sie.
+ ///
+ string? Display = null);
/// Eine Datei hinter einem Titel — eine Fassung also.
///
diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
index b385525..611b2e1 100644
--- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
@@ -1,3 +1,5 @@
+using Flaro.Core.Library;
+using Flaro.Core.Media;
using System.Security.Claims;
using Flaro.Core.Playback;
using Flaro.Data;
@@ -57,7 +59,13 @@ public static class LibraryContentEndpoints
return roh.ToLookup(s => s.MediaFileId, s => new MediaStreamResponse(
s.Index, s.Kind.ToString(), s.Codec, s.Language, s.Title,
- s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height));
+ s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height,
+ Display: s.Kind switch
+ {
+ MediaStreamKind.Video => Benennung.Bildspur(s.Codec, s.Height),
+ MediaStreamKind.Audio => Benennung.Tonspur(s.Language, s.Codec, s.Channels, s.Title),
+ _ => Benennung.Untertitelspur(s.Language, s.IsForced, fuerHoergeschaedigte: false),
+ }));
}
private static MediaFileResponse Datei(
diff --git a/server/src/Flaro.Core/Media/Benennung.cs b/server/src/Flaro.Core/Media/Benennung.cs
new file mode 100644
index 0000000..ce3f8ba
--- /dev/null
+++ b/server/src/Flaro.Core/Media/Benennung.cs
@@ -0,0 +1,215 @@
+namespace Flaro.Core.Media;
+
+/// Macht aus ffprobe-Rohwerten etwas, das man vorlesen kann.
+///
+///
+///
+/// Was vorher in der Oberfläche stand. „DEU EAC3 6.0" und
+/// „DEU MOV_TEXT" — beides ist richtig und beides sagt niemandem etwas, der
+/// nicht weiss, dass EAC3 Dolby Digital Plus heisst und dass sechs Kanäle
+/// 5.1 ergeben und nicht 6.0.
+///
+///
+/// Hier und nicht in der Oberfläche. Die Apps brauchen dieselben
+/// Namen. Zweimal formuliert driften sie auseinander, und dann heisst
+/// dieselbe Tonspur am Fernseher anders als im Browser.
+///
+///
+/// Unbekanntes bleibt stehen. Ein Codec, den diese Liste nicht kennt,
+/// erscheint in Grossbuchstaben — nicht als „Unbekannt". Die rohe Angabe ist
+/// immer noch eine Auskunft; ein Platzhalter ist keine.
+///
+///
+public static class Benennung
+{
+ /// Der Klarname einer Sprache: „deu" wird „Deutsch".
+ ///
+ ///
+ /// Aus einer eigenen Liste, nicht aus .
+ /// Zwei Gründe, beide gemessen: dessen NativeName liefert den Namen
+ /// in der Sprache selbst — „English", „français" —, und für ein erfundenes
+ /// Kürzel wie „xyz" erfindet es stillschweigend eine Kultur, statt zu
+ /// scheitern. Beides taugt hier nicht.
+ ///
+ public static string? Sprache(string? kuerzel, string? anzeigeSprache = null)
+ {
+ if (string.IsNullOrWhiteSpace(kuerzel)) return null;
+
+ // Nur der Sprachteil: „de-DE" und „de" meinen dasselbe.
+ var blank = kuerzel.Trim().Split('-', '_')[0];
+
+ // ffmpeg schreibt „und" für unbekannt. Das als Sprache anzuzeigen wäre
+ // schlimmer als gar nichts — es sieht aus wie eine Angabe.
+ if (blank.Equals("und", StringComparison.OrdinalIgnoreCase)
+ || blank.Equals("unk", StringComparison.OrdinalIgnoreCase)
+ || blank.Equals("mul", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ // Unbekanntes bleibt stehen. Die rohe Angabe ist immer noch eine
+ // Auskunft; „Unbekannt" ist keine.
+ return Sprachnamen.GetValueOrDefault(blank, blank.ToUpperInvariant());
+ }
+
+ /// Die übliche Schreibweise für eine Kanalzahl.
+ ///
+ ///
+ /// Sechs Kanäle sind 5.1, nicht 6.0. Der letzte ist der
+ /// Tiefton-Kanal, und der wird hinter dem Punkt gezählt. Vorher stand in
+ /// der Oberfläche „6.0" — eine Schreibweise, die es nicht gibt.
+ ///
+ public static string? Kanaele(int? anzahl) => anzahl switch
+ {
+ null or <= 0 => null,
+ 1 => "Mono",
+ 2 => "Stereo",
+ 3 => "2.1",
+ 4 => "4.0",
+ 5 => "5.0",
+ 6 => "5.1",
+ 7 => "6.1",
+ 8 => "7.1",
+ _ => $"{anzahl} Kanäle",
+ };
+
+ /// Der geläufige Name eines Codecs.
+ public static string? Codec(string? kuerzel)
+ {
+ if (string.IsNullOrWhiteSpace(kuerzel)) return null;
+ var blank = kuerzel.Trim();
+ return Codecs.GetValueOrDefault(blank.ToLowerInvariant(), blank.ToUpperInvariant());
+ }
+
+ /// Die Auflösungsstufe zu einer gemessenen Bildhöhe.
+ ///
+ ///
+ /// In Stufen, nicht roh. Ein beschnittenes Breitbild misst 1040
+ /// Zeilen statt 1080; genau das stand in der Oberfläche und sah aus wie
+ /// ein Fehler. Der genaue Wert gehört in den Tooltip, nicht in die Zeile.
+ ///
+ public static string? Aufloesung(int? hoehe) => hoehe switch
+ {
+ null or <= 0 => null,
+ >= 2000 => "2160p",
+ >= 1400 => "1440p",
+ >= 1000 => "1080p",
+ >= 700 => "720p",
+ >= 500 => "576p",
+ >= 300 => "480p",
+ _ => $"{hoehe}p",
+ };
+
+ /// Die ganze Zeile zu einer Tonspur.
+ public static string Tonspur(string? sprache, string? codec, int? kanaele, string? titel)
+ {
+ var teile = new List();
+ if (Sprache(sprache) is { } s) teile.Add(s);
+ if (Codec(codec) is { } c) teile.Add(c);
+ if (Kanaele(kanaele) is { } k) teile.Add(k);
+
+ // Der Titel aus der Datei nur, wenn er etwas hinzufügt — „SoundHandler"
+ // ist kein Name, sondern ein Rest aus dem Umpacken.
+ if (titel is { Length: > 0 } && !Nichtssagend.Contains(titel.Trim()))
+ teile.Add($"„{titel.Trim()}“");
+
+ return teile.Count > 0 ? string.Join(" · ", teile) : "Tonspur";
+ }
+
+ /// Die ganze Zeile zu einer Untertitelspur.
+ public static string Untertitelspur(string? sprache, bool erzwungen, bool fuerHoergeschaedigte)
+ {
+ var name = Sprache(sprache) ?? "Untertitel";
+ if (erzwungen) name += " (erzwungen)";
+ else if (fuerHoergeschaedigte) name += " (für Hörgeschädigte)";
+ return name;
+ }
+
+ /// Die ganze Zeile zu einer Bildspur.
+ public static string Bildspur(string? codec, int? hoehe)
+ {
+ var teile = new List();
+ if (Aufloesung(hoehe) is { } a) teile.Add(a);
+ if (Codec(codec) is { } c) teile.Add(c);
+ return teile.Count > 0 ? string.Join(" · ", teile) : "Bild";
+ }
+
+
+ /// „Deutsch (Deutschland)" wird „Deutsch".
+
+
+ private static readonly HashSet Nichtssagend = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "SoundHandler", "VideoHandler", "SubtitleHandler", "Stereo", "Audio", "Video",
+ };
+
+ private static readonly Dictionary Codecs = new(StringComparer.OrdinalIgnoreCase)
+ {
+ // Ton
+ ["eac3"] = "Dolby Digital Plus",
+ ["ac3"] = "Dolby Digital",
+ ["truehd"] = "Dolby TrueHD",
+ ["dts"] = "DTS",
+ ["aac"] = "AAC",
+ ["opus"] = "Opus",
+ ["vorbis"] = "Vorbis",
+ ["flac"] = "FLAC",
+ ["mp3"] = "MP3",
+ ["pcm_s16le"] = "PCM",
+ // Bild
+ ["h264"] = "H.264",
+ ["avc"] = "H.264",
+ ["hevc"] = "H.265",
+ ["h265"] = "H.265",
+ ["av1"] = "AV1",
+ ["vp9"] = "VP9",
+ ["mpeg2video"] = "MPEG-2",
+ ["mpeg4"] = "MPEG-4",
+ // Untertitel
+ ["mov_text"] = "Text",
+ ["subrip"] = "SRT",
+ ["ass"] = "ASS",
+ ["ssa"] = "SSA",
+ ["pgs"] = "PGS",
+ ["hdmv_pgs_subtitle"] = "PGS",
+ ["dvd_subtitle"] = "DVD",
+ };
+
+ /// Sprachnamen auf Deutsch, über das zwei- und das dreibuchstabige Kürzel.
+ ///
+ ///
+ /// Beide Schreibweisen kommen in Mediendateien vor, oft in derselben.
+ /// ISO 639-2 kennt für manche Sprachen zwei Kürzel — „ger" und „deu" —,
+ /// und welches eine Datei trägt, hängt am Werkzeug, das sie erzeugt hat.
+ ///
+ private static readonly Dictionary Sprachnamen = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["de"] = "Deutsch", ["ger"] = "Deutsch", ["deu"] = "Deutsch",
+ ["en"] = "Englisch", ["eng"] = "Englisch",
+ ["fr"] = "Französisch", ["fre"] = "Französisch", ["fra"] = "Französisch",
+ ["es"] = "Spanisch", ["spa"] = "Spanisch",
+ ["it"] = "Italienisch", ["ita"] = "Italienisch",
+ ["nl"] = "Niederländisch", ["dut"] = "Niederländisch", ["nld"] = "Niederländisch",
+ ["pl"] = "Polnisch", ["pol"] = "Polnisch",
+ ["pt"] = "Portugiesisch", ["por"] = "Portugiesisch",
+ ["ru"] = "Russisch", ["rus"] = "Russisch",
+ ["tr"] = "Türkisch", ["tur"] = "Türkisch",
+ ["ja"] = "Japanisch", ["jpn"] = "Japanisch",
+ ["ko"] = "Koreanisch", ["kor"] = "Koreanisch",
+ ["zh"] = "Chinesisch", ["chi"] = "Chinesisch", ["zho"] = "Chinesisch",
+ ["sv"] = "Schwedisch", ["swe"] = "Schwedisch",
+ ["no"] = "Norwegisch", ["nor"] = "Norwegisch",
+ ["da"] = "Dänisch", ["dan"] = "Dänisch",
+ ["fi"] = "Finnisch", ["fin"] = "Finnisch",
+ ["cs"] = "Tschechisch", ["cze"] = "Tschechisch", ["ces"] = "Tschechisch",
+ ["hu"] = "Ungarisch", ["hun"] = "Ungarisch",
+ ["el"] = "Griechisch", ["gre"] = "Griechisch", ["ell"] = "Griechisch",
+ ["ar"] = "Arabisch", ["ara"] = "Arabisch",
+ ["he"] = "Hebräisch", ["heb"] = "Hebräisch",
+ ["hi"] = "Hindi", ["hin"] = "Hindi",
+ ["ro"] = "Rumänisch", ["rum"] = "Rumänisch", ["ron"] = "Rumänisch",
+ ["uk"] = "Ukrainisch", ["ukr"] = "Ukrainisch",
+ ["th"] = "Thailändisch", ["tha"] = "Thailändisch",
+ ["la"] = "Latein", ["lat"] = "Latein",
+ };
+}
diff --git a/server/tests/Flaro.Core.Tests/BenennungTests.cs b/server/tests/Flaro.Core.Tests/BenennungTests.cs
new file mode 100644
index 0000000..c336ad6
--- /dev/null
+++ b/server/tests/Flaro.Core.Tests/BenennungTests.cs
@@ -0,0 +1,85 @@
+using Flaro.Core.Media;
+
+namespace Flaro.Core.Tests;
+
+/// Aus ffprobe-Rohwerten wird etwas, das man vorlesen kann.
+public class BenennungTests
+{
+ [TestCase("deu", "Deutsch")]
+ [TestCase("ger", "Deutsch")]
+ [TestCase("de", "Deutsch")]
+ [TestCase("eng", "Englisch")]
+ [TestCase("fr", "Französisch")]
+ public void Sprachkuerzel_werden_Klarnamen(string kuerzel, string erwartet) =>
+ Assert.That(Benennung.Sprache(kuerzel), Is.EqualTo(erwartet));
+
+ /// „und" heisst unbekannt und darf nicht wie eine Angabe aussehen.
+ [TestCase("und")]
+ [TestCase("unk")]
+ [TestCase("")]
+ [TestCase(null)]
+ public void Unbekanntes_bleibt_leer(string? kuerzel) =>
+ Assert.That(Benennung.Sprache(kuerzel), Is.Null);
+
+ /// Ein unbekanntes Kürzel bleibt stehen — es ist immer noch eine Auskunft.
+ [Test]
+ public void Fremdes_bleibt_stehen() =>
+ Assert.That(Benennung.Sprache("xyz"), Is.EqualTo("XYZ"));
+
+ /// Sechs Kanäle sind 5.1 — „6.0" gibt es nicht.
+ [TestCase(1, "Mono")]
+ [TestCase(2, "Stereo")]
+ [TestCase(6, "5.1")]
+ [TestCase(8, "7.1")]
+ [TestCase(null, null)]
+ public void Kanalzahlen_bekommen_die_uebliche_Schreibweise(int? kanaele, string? erwartet) =>
+ Assert.That(Benennung.Kanaele(kanaele), Is.EqualTo(erwartet));
+
+ [TestCase("eac3", "Dolby Digital Plus")]
+ [TestCase("h264", "H.264")]
+ [TestCase("hevc", "H.265")]
+ [TestCase("mov_text", "Text")]
+ [TestCase("exotisch", "EXOTISCH")]
+ public void Codecs_bekommen_ihren_geläufigen_Namen(string kuerzel, string erwartet) =>
+ Assert.That(Benennung.Codec(kuerzel), Is.EqualTo(erwartet));
+
+ /// Ein beschnittenes Breitbild misst 1040 Zeilen und ist trotzdem 1080p.
+ [TestCase(2160, "2160p")]
+ [TestCase(1080, "1080p")]
+ [TestCase(1040, "1080p")]
+ [TestCase(720, "720p")]
+ public void Bildhoehen_werden_zu_Stufen(int hoehe, string erwartet) =>
+ Assert.That(Benennung.Aufloesung(hoehe), Is.EqualTo(erwartet));
+
+ /// Der Fall aus der Oberfläche, Wort für Wort.
+ [Test]
+ public void Die_ganze_Tonspur()
+ {
+ Assert.That(Benennung.Tonspur("deu", "eac3", 6, null),
+ Is.EqualTo("Deutsch · Dolby Digital Plus · 5.1"));
+ Assert.That(Benennung.Tonspur("eng", "aac", 2, null),
+ Is.EqualTo("Englisch · AAC · Stereo"));
+ }
+
+ /// „SoundHandler" ist kein Name, sondern ein Rest aus dem Umpacken.
+ [Test]
+ public void Nichtssagende_Titel_fallen_weg() =>
+ Assert.That(Benennung.Tonspur("deu", "aac", 2, "SoundHandler"),
+ Is.EqualTo("Deutsch · AAC · Stereo"));
+
+ [Test]
+ public void Ein_echter_Spurtitel_bleibt() =>
+ Assert.That(Benennung.Tonspur("deu", "aac", 2, "Audiodeskription"),
+ Does.Contain("Audiodeskription"));
+
+ [Test]
+ public void Untertitel_zeigen_ihre_Besonderheit()
+ {
+ Assert.That(Benennung.Untertitelspur("deu", false, false), Is.EqualTo("Deutsch"));
+ Assert.That(Benennung.Untertitelspur("deu", true, false), Is.EqualTo("Deutsch (erzwungen)"));
+ }
+
+ [Test]
+ public void Die_ganze_Bildspur() =>
+ Assert.That(Benennung.Bildspur("h264", 1040), Is.EqualTo("1080p · H.264"));
+}
diff --git a/web/app/src/api.ts b/web/app/src/api.ts
index f0f4165..6aac59a 100644
--- a/web/app/src/api.ts
+++ b/web/app/src/api.ts
@@ -13,6 +13,8 @@ export type Spur = {
codec: string | null; language: string | null; title: string | null;
isDefault: boolean; isForced: boolean;
channels: number | null; width: number | null; height: number | null;
+ /** Die fertige Zeile vom Server, etwa Deutsch · Dolby Digital Plus · 5.1. */
+ display: string | null;
};
export type Datei = {
id: string; fileName: string; sizeBytes: number;
diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts
index d2100aa..e2aa41b 100644
--- a/web/app/src/views/detail.ts
+++ b/web/app/src/views/detail.ts
@@ -273,11 +273,16 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
return liste;
}
+ /** Der Name einer Spur — vom Server, nicht hier zusammengesetzt.
+ *
+ * **Vorher stand hier „DEU EAC3 6.0".** Jedes Stück davon war richtig und
+ * keines verständlich: EAC3 heisst Dolby Digital Plus, und sechs Kanäle
+ * sind 5.1, nicht 6.0. Der Server formuliert das jetzt, weil die Apps
+ * denselben Satz brauchen — zweimal formuliert driftet er auseinander. */
function spurname(s: Datei['streams'][number]): string {
- const teile = [s.title, s.language?.toUpperCase(), s.codec?.toUpperCase()]
- .filter(Boolean);
- const kanaele = s.channels ? `${s.channels}.0` : null;
- return [teile.join(' '), kanaele].filter(Boolean).join(' ');
+ if (s.display) return s.display;
+ // Rückfall für einen älteren Server, der das Feld noch nicht schickt.
+ return [s.language?.toUpperCase(), s.codec?.toUpperCase()].filter(Boolean).join(' ');
}
/* ---------- Besetzung ---------- */