Aus "DEU EAC3 6.0" wird "Deutsch · Dolby Digital Plus · 5.1"

Die Werte gingen roh von ffprobe in die Anzeige. Jedes Stück war richtig
und keines verständlich: EAC3 heisst Dolby Digital Plus, MOV_TEXT heisst
schlicht Text, und sechs Kanäle sind 5.1 — "6.0" ist eine Schreibweise,
die es nicht gibt. Die Auflösung stand als gemessene Höhe da, also "1040p"
für ein beschnittenes Breitbild, was aussieht wie ein Fehler.

Benennung macht daraus Sätze, und zwar im Kern: die Apps brauchen
dieselben, und zweimal formuliert heisst dieselbe Tonspur am Fernseher
anders als im Browser.

CultureInfo taugte dafür nicht, zweimal gemessen: sein 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. Also eine eigene Liste, über das zwei- und das
dreibuchstabige Kürzel, weil beide in Mediendateien vorkommen und ISO
639-2 für manche Sprachen zwei kennt.

Unbekanntes bleibt stehen statt zu "Unbekannt" zu werden — die rohe
Angabe ist immer noch eine Auskunft. Und "und" verschwindet ganz: ffmpeg
schreibt das für unbekannt, und als Sprache angezeigt sieht es aus wie
eine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 12:28:17 +02:00
co-authored by Claude Opus 5
parent 921533050b
commit 0cd88acf70
6 changed files with 331 additions and 6 deletions
+11 -1
View File
@@ -110,7 +110,17 @@ public sealed record MetadataProgress(
/// <summary>Eine Spur in einer Datei.</summary> /// <summary>Eine Spur in einer Datei.</summary>
public sealed record MediaStreamResponse( public sealed record MediaStreamResponse(
int Index, string Kind, string? Codec, string? Language, string? Title, 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,
/// <summary>Die Zeile, wie sie angezeigt werden soll.</summary>
///
/// <remarks>
/// <b>Vom Server formuliert, nicht vom Client.</b> „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.
/// </remarks>
string? Display = null);
/// <summary>Eine Datei hinter einem Titel — eine Fassung also.</summary> /// <summary>Eine Datei hinter einem Titel — eine Fassung also.</summary>
/// ///
@@ -1,3 +1,5 @@
using Flaro.Core.Library;
using Flaro.Core.Media;
using System.Security.Claims; using System.Security.Claims;
using Flaro.Core.Playback; using Flaro.Core.Playback;
using Flaro.Data; using Flaro.Data;
@@ -57,7 +59,13 @@ public static class LibraryContentEndpoints
return roh.ToLookup(s => s.MediaFileId, s => new MediaStreamResponse( return roh.ToLookup(s => s.MediaFileId, s => new MediaStreamResponse(
s.Index, s.Kind.ToString(), s.Codec, s.Language, s.Title, 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( private static MediaFileResponse Datei(
+215
View File
@@ -0,0 +1,215 @@
namespace Flaro.Core.Media;
/// <summary>Macht aus ffprobe-Rohwerten etwas, das man vorlesen kann.</summary>
///
/// <remarks>
/// <para>
/// <b>Was vorher in der Oberfläche stand.</b> „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.
/// </para>
/// <para>
/// <b>Hier und nicht in der Oberfläche.</b> Die Apps brauchen dieselben
/// Namen. Zweimal formuliert driften sie auseinander, und dann heisst
/// dieselbe Tonspur am Fernseher anders als im Browser.
/// </para>
/// <para>
/// <b>Unbekanntes bleibt stehen.</b> 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.
/// </para>
/// </remarks>
public static class Benennung
{
/// <summary>Der Klarname einer Sprache: „deu" wird „Deutsch".</summary>
///
/// <remarks>
/// <b>Aus einer eigenen Liste, nicht aus <see cref="CultureInfo"/>.</b>
/// Zwei Gründe, beide gemessen: dessen <c>NativeName</c> 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.
/// </remarks>
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());
}
/// <summary>Die übliche Schreibweise für eine Kanalzahl.</summary>
///
/// <remarks>
/// <b>Sechs Kanäle sind 5.1, nicht 6.0.</b> 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.
/// </remarks>
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",
};
/// <summary>Der geläufige Name eines Codecs.</summary>
public static string? Codec(string? kuerzel)
{
if (string.IsNullOrWhiteSpace(kuerzel)) return null;
var blank = kuerzel.Trim();
return Codecs.GetValueOrDefault(blank.ToLowerInvariant(), blank.ToUpperInvariant());
}
/// <summary>Die Auflösungsstufe zu einer gemessenen Bildhöhe.</summary>
///
/// <remarks>
/// <b>In Stufen, nicht roh.</b> 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.
/// </remarks>
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",
};
/// <summary>Die ganze Zeile zu einer Tonspur.</summary>
public static string Tonspur(string? sprache, string? codec, int? kanaele, string? titel)
{
var teile = new List<string>();
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";
}
/// <summary>Die ganze Zeile zu einer Untertitelspur.</summary>
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;
}
/// <summary>Die ganze Zeile zu einer Bildspur.</summary>
public static string Bildspur(string? codec, int? hoehe)
{
var teile = new List<string>();
if (Aufloesung(hoehe) is { } a) teile.Add(a);
if (Codec(codec) is { } c) teile.Add(c);
return teile.Count > 0 ? string.Join(" · ", teile) : "Bild";
}
/// <summary>„Deutsch (Deutschland)" wird „Deutsch".</summary>
private static readonly HashSet<string> Nichtssagend = new(StringComparer.OrdinalIgnoreCase)
{
"SoundHandler", "VideoHandler", "SubtitleHandler", "Stereo", "Audio", "Video",
};
private static readonly Dictionary<string, string> 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",
};
/// <summary>Sprachnamen auf Deutsch, über das zwei- und das dreibuchstabige Kürzel.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
private static readonly Dictionary<string, string> 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",
};
}
@@ -0,0 +1,85 @@
using Flaro.Core.Media;
namespace Flaro.Core.Tests;
/// <summary>Aus ffprobe-Rohwerten wird etwas, das man vorlesen kann.</summary>
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));
/// <summary>„und" heisst unbekannt und darf nicht wie eine Angabe aussehen.</summary>
[TestCase("und")]
[TestCase("unk")]
[TestCase("")]
[TestCase(null)]
public void Unbekanntes_bleibt_leer(string? kuerzel) =>
Assert.That(Benennung.Sprache(kuerzel), Is.Null);
/// <summary>Ein unbekanntes Kürzel bleibt stehen — es ist immer noch eine Auskunft.</summary>
[Test]
public void Fremdes_bleibt_stehen() =>
Assert.That(Benennung.Sprache("xyz"), Is.EqualTo("XYZ"));
/// <summary>Sechs Kanäle sind 5.1 — „6.0" gibt es nicht.</summary>
[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));
/// <summary>Ein beschnittenes Breitbild misst 1040 Zeilen und ist trotzdem 1080p.</summary>
[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));
/// <summary>Der Fall aus der Oberfläche, Wort für Wort.</summary>
[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"));
}
/// <summary>„SoundHandler" ist kein Name, sondern ein Rest aus dem Umpacken.</summary>
[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"));
}
+2
View File
@@ -13,6 +13,8 @@ export type Spur = {
codec: string | null; language: string | null; title: string | null; codec: string | null; language: string | null; title: string | null;
isDefault: boolean; isForced: boolean; isDefault: boolean; isForced: boolean;
channels: number | null; width: number | null; height: number | null; 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 = { export type Datei = {
id: string; fileName: string; sizeBytes: number; id: string; fileName: string; sizeBytes: number;
+9 -4
View File
@@ -273,11 +273,16 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
return liste; 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 { function spurname(s: Datei['streams'][number]): string {
const teile = [s.title, s.language?.toUpperCase(), s.codec?.toUpperCase()] if (s.display) return s.display;
.filter(Boolean); // Rückfall für einen älteren Server, der das Feld noch nicht schickt.
const kanaele = s.channels ? `${s.channels}.0` : null; return [s.language?.toUpperCase(), s.codec?.toUpperCase()].filter(Boolean).join(' ');
return [teile.join(' '), kanaele].filter(Boolean).join(' ');
} }
/* ---------- Besetzung ---------- */ /* ---------- Besetzung ---------- */