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 ---------- */