Der Kern der Sprachwahl: Kürzel, die nicht lügen, und Abwägen statt Filtern
**`LanguageCode`** bringt Kürzel auf eine vergleichbare Form. Der Anlass ist ein stiller Fehler: verglichen wurden die ersten zwei Zeichen, also `ge` gegen `de` — und `ger` ist die ISO-639-2/B-Form von Deutsch, die ffmpeg in viele Dateien schreibt. Bei solchen Titeln wirkte die Spracheinstellung überhaupt nicht. `gsw` bleibt `gsw`. Schweizerdeutsch ist keine Schreibweise von Deutsch, und es gleichzusetzen hiesse, jemandem Schweizerdeutsch unterzuschieben, der Hochdeutsch wollte. Es gehört in die Rangliste, nicht in eine Gleichsetzung. **`AudioTrackChooser`** wägt ab, statt zu filtern: jede Spur bekommt Punkte, die höchste gewinnt. Damit gibt es immer eine Antwort, und Audiodeskription wird gemieden, ohne unmöglich zu werden — gibt es eine Sprache nur mit Audiodeskription, ist sie besser als Stille. Gemerkt wird nicht eine Nummer, sondern eine Beschreibung der Spur: Folgen derselben Serie haben unterschiedlich sortierte Spuren, und „Spur 2" bedeutet in der nächsten Folge etwas anderes. Siebzehn Tests, darunter der ZDF-Fall wörtlich — deutsche Spur mit Audiodeskription als Standard, englisch betitelte deutsche Spur daneben — und zwar in beide Richtungen: mit Meiden gewinnt die richtige, ohne Meiden gewinnt das Kürzel. Sonst stünde der Schalter nur dabei. Dazu trägt `MediaStream` jetzt `title`, `isDefault`, `isHearingImpaired` und `isOriginal`. Der Name aus der Datei ist das zweite Signal neben dem Kürzel und manchmal das einzige richtige. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
946de29893
commit
afb4a63655
@@ -0,0 +1,180 @@
|
|||||||
|
import Foundation
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// Welche Tonspur genommen wird — und warum.
|
||||||
|
///
|
||||||
|
/// **Warum als reiner Typ.** Spuren und Wunsch hinein, eine Spur heraus. Kein
|
||||||
|
/// Netz, keine Einstellungen, keine Ansicht. Dieselbe Bauform wie
|
||||||
|
/// `PlaybackDecider` und `StreamHealth`, und aus demselben Grund: eine
|
||||||
|
/// Auswahl, die man nur im laufenden Film beobachten kann, ist nicht zu
|
||||||
|
/// beurteilen. Hier lässt sich der ZDF-Fall wörtlich hinschreiben.
|
||||||
|
///
|
||||||
|
/// **Warum es nicht reicht, das Kürzel zu vergleichen.** Weil Kürzel lügen.
|
||||||
|
/// Es gibt Dateien, in denen die als deutsch ausgezeichnete Spur die mit
|
||||||
|
/// Audiodeskription ist und die als englisch ausgezeichnete die eigentliche
|
||||||
|
/// deutsche. Jellyfin hat **kein Kennzeichen für Audiodeskription** —
|
||||||
|
/// `MediaStream` führt `IsDefault`, `IsForced`, `IsHearingImpaired`,
|
||||||
|
/// `IsOriginal`, mehr nicht. Bleibt der Name der Spur, und der ist Text.
|
||||||
|
///
|
||||||
|
/// Deshalb kein Filtern, sondern **Abwägen**: jede Spur bekommt Punkte, die
|
||||||
|
/// höchste gewinnt. Damit gibt es immer eine Antwort, auch wenn nichts passt
|
||||||
|
/// — und Audiodeskription wird gemieden, ohne unmöglich zu werden.
|
||||||
|
public enum AudioTrackChooser {
|
||||||
|
/// Was gewünscht ist.
|
||||||
|
public struct Wish: Sendable, Equatable {
|
||||||
|
/// Die Sprache, die gelten soll — aus Serie, Mediathek oder allgemein.
|
||||||
|
public let language: String?
|
||||||
|
/// Die Rangliste, wenn die Wunschsprache fehlt. Von oben nach unten.
|
||||||
|
public let fallbacks: [String]
|
||||||
|
/// Was zuletzt für **diese Serie** gewählt wurde.
|
||||||
|
public let remembered: TrackSignature?
|
||||||
|
/// Ob Audiodeskription gemieden werden soll.
|
||||||
|
public let avoidsAudioDescription: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
language: String? = nil,
|
||||||
|
fallbacks: [String] = [],
|
||||||
|
remembered: TrackSignature? = nil,
|
||||||
|
avoidsAudioDescription: Bool = true
|
||||||
|
) {
|
||||||
|
self.language = language
|
||||||
|
self.fallbacks = fallbacks
|
||||||
|
self.remembered = remembered
|
||||||
|
self.avoidsAudioDescription = avoidsAudioDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie eine Spur aussieht — genug, um sie in der nächsten Folge
|
||||||
|
/// wiederzuerkennen.
|
||||||
|
///
|
||||||
|
/// **Keine Nummer.** Folgen derselben Serie haben unterschiedlich
|
||||||
|
/// sortierte Spuren; „Spur 2" bedeutet in der nächsten Folge etwas
|
||||||
|
/// anderes. Eine Beschreibung überlebt das.
|
||||||
|
public struct TrackSignature: Sendable, Equatable, Codable {
|
||||||
|
public let languageCode: String?
|
||||||
|
public let title: String?
|
||||||
|
public let channels: Int?
|
||||||
|
public let codec: String?
|
||||||
|
|
||||||
|
public init(languageCode: String?, title: String?, channels: Int?, codec: String?) {
|
||||||
|
self.languageCode = LanguageCode.normalized(languageCode)
|
||||||
|
self.title = title?.lowercased()
|
||||||
|
self.channels = channels
|
||||||
|
self.codec = codec?.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(_ stream: MediaStream) {
|
||||||
|
self.init(
|
||||||
|
languageCode: stream.languageCode,
|
||||||
|
title: stream.title ?? stream.displayTitle,
|
||||||
|
channels: stream.channels,
|
||||||
|
codec: stream.codec
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie gut eine Spur zu dieser Beschreibung passt — 0 bis 4.
|
||||||
|
func similarity(to stream: MediaStream) -> Int {
|
||||||
|
var punkte = 0
|
||||||
|
if let languageCode, LanguageCode.matches(languageCode, stream.languageCode) { punkte += 1 }
|
||||||
|
if let title, let anderer = (stream.title ?? stream.displayTitle)?.lowercased(),
|
||||||
|
title == anderer { punkte += 2 }
|
||||||
|
if let channels, channels == stream.channels { punkte += 1 }
|
||||||
|
return punkte
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wortmarken für Audiodeskription.
|
||||||
|
///
|
||||||
|
/// Nur am Text zu erkennen, weil es kein Kennzeichen dafür gibt. Die
|
||||||
|
/// Liste steht an **einer** Stelle und ist geprüft.
|
||||||
|
static let audioDescriptionMarkers = [
|
||||||
|
"audiodeskription", "audio-deskription", "audio description",
|
||||||
|
"audiodescription", "hörfilm", "hoerfilm", "descriptive",
|
||||||
|
"described", "narration",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Wortmarken für Kommentarspuren.
|
||||||
|
static let commentaryMarkers = [
|
||||||
|
"kommentar", "commentary", "director", "regisseur",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Wählt die Spur.
|
||||||
|
///
|
||||||
|
/// Gibt `nil` zurück, wenn es keine einzige gibt — sonst immer eine.
|
||||||
|
public static func choose(from streams: [MediaStream], wish: Wish) -> MediaStream? {
|
||||||
|
let tonspuren = streams.filter { $0.kind == .audio }
|
||||||
|
guard !tonspuren.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let bewertet = tonspuren.map { (stream: $0, punkte: score($0, wish: wish)) }
|
||||||
|
// Bei Gleichstand die kleinere Nummer: dieselbe Datei soll immer
|
||||||
|
// dasselbe ergeben, sonst ist ein Fehler nicht wiederholbar.
|
||||||
|
return bewertet
|
||||||
|
.sorted {
|
||||||
|
$0.punkte == $1.punkte
|
||||||
|
? ($0.stream.index ?? .max) < ($1.stream.index ?? .max)
|
||||||
|
: $0.punkte > $1.punkte
|
||||||
|
}
|
||||||
|
.first?
|
||||||
|
.stream
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Punkte einer einzelnen Spur. Offen gelegt, damit die Tests die
|
||||||
|
/// Abwägung selbst nachrechnen können.
|
||||||
|
public static func score(_ stream: MediaStream, wish: Wish) -> Int {
|
||||||
|
var punkte = 0
|
||||||
|
|
||||||
|
// Was zuletzt gewählt wurde, wiegt am schwersten — es ist die einzige
|
||||||
|
// Angabe, die von einem Menschen kommt und nicht aus der Datei.
|
||||||
|
if let remembered = wish.remembered {
|
||||||
|
punkte += remembered.similarity(to: stream) * 25
|
||||||
|
}
|
||||||
|
|
||||||
|
if let language = wish.language, LanguageCode.matches(language, stream.languageCode) {
|
||||||
|
punkte += 60
|
||||||
|
} else {
|
||||||
|
// Die Rangliste, von oben nach unten. Weiter unten heißt weniger.
|
||||||
|
for (rang, sprache) in wish.fallbacks.enumerated()
|
||||||
|
where LanguageCode.matches(sprache, stream.languageCode) {
|
||||||
|
punkte += max(5, 50 - rang * 5)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if stream.isDefault { punkte += 10 }
|
||||||
|
punkte += stream.channels ?? 0
|
||||||
|
|
||||||
|
// **Abzug, kein Ausschluss.** Gibt es eine Sprache nur mit
|
||||||
|
// Audiodeskription, ist sie immer noch besser als Stille.
|
||||||
|
if wish.avoidsAudioDescription, looksLikeAudioDescription(stream) { punkte -= 200 }
|
||||||
|
if looksLikeCommentary(stream) { punkte -= 200 }
|
||||||
|
|
||||||
|
return punkte
|
||||||
|
}
|
||||||
|
|
||||||
|
static func looksLikeAudioDescription(_ stream: MediaStream) -> Bool {
|
||||||
|
contains(stream, any: audioDescriptionMarkers) || standsAlone(stream, marker: "ad")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func looksLikeCommentary(_ stream: MediaStream) -> Bool {
|
||||||
|
contains(stream, any: commentaryMarkers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func contains(_ stream: MediaStream, any markers: [String]) -> Bool {
|
||||||
|
let text = [stream.title, stream.displayTitle]
|
||||||
|
.compactMap(\.self)
|
||||||
|
.joined(separator: " ")
|
||||||
|
.lowercased()
|
||||||
|
return markers.contains { text.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// „AD" als eigenes Wort — nicht als Teil von „Broadcast" oder „Advanced".
|
||||||
|
private static func standsAlone(_ stream: MediaStream, marker: String) -> Bool {
|
||||||
|
let text = [stream.title, stream.displayTitle]
|
||||||
|
.compactMap(\.self)
|
||||||
|
.joined(separator: " ")
|
||||||
|
.lowercased()
|
||||||
|
return text
|
||||||
|
.split(whereSeparator: { !$0.isLetter && !$0.isNumber })
|
||||||
|
.contains { $0 == marker }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Sprachkürzel vergleichen, ohne auf ihre Schreibweise hereinzufallen.
|
||||||
|
///
|
||||||
|
/// **Warum es das gibt.** Verglichen wurden bisher die ersten zwei Zeichen.
|
||||||
|
/// Das ist in einem Fall falsch, und ausgerechnet der ist häufig:
|
||||||
|
///
|
||||||
|
/// deu ~ de → Treffer
|
||||||
|
/// ger ~ de → KEIN Treffer
|
||||||
|
/// de-DE ~ de → Treffer
|
||||||
|
///
|
||||||
|
/// `ger` ist die ISO-639-2/B-Form von Deutsch, und ffmpeg schreibt sie in
|
||||||
|
/// viele Dateien. Bei solchen Titeln wirkte die Spracheinstellung
|
||||||
|
/// **überhaupt nicht** — lautlos, ohne dass irgendetwas darauf hindeutete.
|
||||||
|
///
|
||||||
|
/// Zwanzig Sprachen haben zwei verschiedene dreibuchstabige Formen: eine
|
||||||
|
/// bibliografische (/B, aus dem Englischen) und eine terminologische (/T, aus
|
||||||
|
/// der Sprache selbst). Nur die zwanzig stehen unten — für alle anderen ist
|
||||||
|
/// die dreibuchstabige Form eindeutig, und eine vollständige Tabelle wäre
|
||||||
|
/// siebenhundert Zeilen, die niemand prüft.
|
||||||
|
public enum LanguageCode {
|
||||||
|
/// Paare, bei denen dieselbe Sprache zwei dreibuchstabige Kürzel hat.
|
||||||
|
///
|
||||||
|
/// Zusätzlich die zweibuchstabige Form, denn das ist das Ziel: alles,
|
||||||
|
/// was dasselbe meint, soll auf denselben Wert fallen.
|
||||||
|
private static let aliases: [String: String] = [
|
||||||
|
"alb": "sq", "sqi": "sq",
|
||||||
|
"arm": "hy", "hye": "hy",
|
||||||
|
"baq": "eu", "eus": "eu",
|
||||||
|
"bur": "my", "mya": "my",
|
||||||
|
"chi": "zh", "zho": "zh",
|
||||||
|
"cze": "cs", "ces": "cs",
|
||||||
|
"dut": "nl", "nld": "nl",
|
||||||
|
"fre": "fr", "fra": "fr",
|
||||||
|
"geo": "ka", "kat": "ka",
|
||||||
|
"ger": "de", "deu": "de",
|
||||||
|
"gre": "el", "ell": "el",
|
||||||
|
"ice": "is", "isl": "is",
|
||||||
|
"mac": "mk", "mkd": "mk",
|
||||||
|
"mao": "mi", "mri": "mi",
|
||||||
|
"may": "ms", "msa": "ms",
|
||||||
|
"per": "fa", "fas": "fa",
|
||||||
|
"rum": "ro", "ron": "ro",
|
||||||
|
"slo": "sk", "slk": "sk",
|
||||||
|
"tib": "bo", "bod": "bo",
|
||||||
|
"wel": "cy", "cym": "cy",
|
||||||
|
// Die häufigen, die keine zwei Formen haben, aber oft dreibuchstabig
|
||||||
|
// in Dateien stehen.
|
||||||
|
"eng": "en", "spa": "es", "ita": "it", "por": "pt", "rus": "ru",
|
||||||
|
"jpn": "ja", "kor": "ko", "pol": "pl", "swe": "sv", "nor": "no",
|
||||||
|
"dan": "da", "fin": "fi", "tur": "tr", "ara": "ar", "heb": "he",
|
||||||
|
"hin": "hi", "tha": "th", "vie": "vi", "ukr": "uk", "hun": "hu",
|
||||||
|
"bul": "bg", "hrv": "hr", "srp": "sr", "slv": "sl", "est": "et",
|
||||||
|
"lav": "lv", "lit": "lt", "cat": "ca", "gle": "ga",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Bringt ein Kürzel auf eine Form, die sich vergleichen lässt.
|
||||||
|
///
|
||||||
|
/// **`gsw` bleibt `gsw`.** Schweizerdeutsch ist keine Schreibweise von
|
||||||
|
/// Deutsch, sondern eine eigene Sprache ohne zweibuchstabiges Kürzel. Es
|
||||||
|
/// auf `de` abzubilden wäre bequem und falsch: wer Hochdeutsch will,
|
||||||
|
/// bekäme Schweizerdeutsch untergeschoben. Dasselbe gilt für Bairisch
|
||||||
|
/// (`bar`), Plattdeutsch (`nds`) und alles andere dieser Art — sie gehören
|
||||||
|
/// in die Rangliste, nicht in eine Gleichsetzung.
|
||||||
|
public static func normalized(_ raw: String?) -> String? {
|
||||||
|
guard let raw else { return nil }
|
||||||
|
// Region abschneiden: „de-DE", „pt_BR", „zh-Hans".
|
||||||
|
let ohneRegion = raw
|
||||||
|
.lowercased()
|
||||||
|
.split(whereSeparator: { $0 == "-" || $0 == "_" })
|
||||||
|
.first
|
||||||
|
.map(String.init) ?? ""
|
||||||
|
guard !ohneRegion.isEmpty else { return nil }
|
||||||
|
|
||||||
|
if let bekannt = aliases[ohneRegion] { return bekannt }
|
||||||
|
return ohneRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob zwei Kürzel dieselbe Sprache meinen.
|
||||||
|
public static func matches(_ lhs: String?, _ rhs: String?) -> Bool {
|
||||||
|
guard let links = normalized(lhs), let rechts = normalized(rhs) else { return false }
|
||||||
|
return links == rechts
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,7 +159,11 @@ extension JellyfinMediaStream {
|
|||||||
displayTitle: displayTitle,
|
displayTitle: displayTitle,
|
||||||
channels: channels,
|
channels: channels,
|
||||||
isSelected: isDefault ?? false,
|
isSelected: isDefault ?? false,
|
||||||
isForced: isForced ?? false
|
isForced: isForced ?? false,
|
||||||
|
title: title,
|
||||||
|
isDefault: isDefault ?? false,
|
||||||
|
isHearingImpaired: isHearingImpaired ?? false,
|
||||||
|
isOriginal: isOriginal ?? false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,19 @@ public struct MediaStream: Sendable, Equatable, Identifiable {
|
|||||||
public let isSelected: Bool
|
public let isSelected: Bool
|
||||||
/// Untertitel, die auch bei abgeschalteten Untertiteln erscheinen sollen.
|
/// Untertitel, die auch bei abgeschalteten Untertiteln erscheinen sollen.
|
||||||
public let isForced: Bool
|
public let isForced: Bool
|
||||||
|
/// Wie die Spur **in der Datei** heißt.
|
||||||
|
///
|
||||||
|
/// Das zweite Signal neben dem Sprachkürzel — und manchmal das einzige
|
||||||
|
/// richtige. Wo im Kürzel „eng" steht, kann hier „Deutsch" stehen; genau
|
||||||
|
/// so sehen die Dateien aus, bei denen die Sprachwahl bisher danebengriff.
|
||||||
|
public let title: String?
|
||||||
|
/// Die Spur, die der Server als Standard markiert.
|
||||||
|
public let isDefault: Bool
|
||||||
|
/// Für Hörgeschädigte. **Nicht** Audiodeskription — dafür gibt es bei
|
||||||
|
/// Jellyfin kein Kennzeichen, die lässt sich nur am Namen erkennen.
|
||||||
|
public let isHearingImpaired: Bool
|
||||||
|
/// Die Originaltonspur, sofern die Datei das vermerkt.
|
||||||
|
public let isOriginal: Bool
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -184,7 +197,11 @@ public struct MediaStream: Sendable, Equatable, Identifiable {
|
|||||||
displayTitle: String? = nil,
|
displayTitle: String? = nil,
|
||||||
channels: Int? = nil,
|
channels: Int? = nil,
|
||||||
isSelected: Bool = false,
|
isSelected: Bool = false,
|
||||||
isForced: Bool = false
|
isForced: Bool = false,
|
||||||
|
title: String? = nil,
|
||||||
|
isDefault: Bool = false,
|
||||||
|
isHearingImpaired: Bool = false,
|
||||||
|
isOriginal: Bool = false
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
@@ -195,8 +212,15 @@ public struct MediaStream: Sendable, Equatable, Identifiable {
|
|||||||
self.channels = channels
|
self.channels = channels
|
||||||
self.isSelected = isSelected
|
self.isSelected = isSelected
|
||||||
self.isForced = isForced
|
self.isForced = isForced
|
||||||
|
self.title = title
|
||||||
|
self.isDefault = isDefault
|
||||||
|
self.isHearingImpaired = isHearingImpaired
|
||||||
|
self.isOriginal = isOriginal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Nummer, unter der der Server diese Spur führt.
|
||||||
|
public var index: Int? { Int(id) }
|
||||||
|
|
||||||
/// Ein brauchbarer Name, auch wenn der Server keinen liefert.
|
/// Ein brauchbarer Name, auch wenn der Server keinen liefert.
|
||||||
///
|
///
|
||||||
/// „Spur 3" hilft niemandem bei der Wahl; Sprache und Codec schon.
|
/// „Spur 3" hilft niemandem bei der Wahl; Sprache und Codec schon.
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
import MediaKit
|
||||||
|
@testable import FlixyPlayback
|
||||||
|
|
||||||
|
@Suite("Sprachkürzel")
|
||||||
|
struct LanguageCodeTests {
|
||||||
|
/// Der Fall, an dem die alte Prüfung lautlos scheiterte: `ger` ist die
|
||||||
|
/// ISO-639-2/B-Form von Deutsch, und ffmpeg schreibt sie in viele Dateien.
|
||||||
|
@Test("ger, deu und de-DE meinen alle Deutsch")
|
||||||
|
func germanHasThreeSpellings() {
|
||||||
|
#expect(LanguageCode.matches("ger", "de"))
|
||||||
|
#expect(LanguageCode.matches("deu", "de"))
|
||||||
|
#expect(LanguageCode.matches("de-DE", "de"))
|
||||||
|
#expect(LanguageCode.matches("GER", "de"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schweizerdeutsch ist keine Schreibweise von Deutsch. Es gleichzusetzen
|
||||||
|
/// wäre bequem und falsch — wer Hochdeutsch will, bekäme sonst
|
||||||
|
/// Schweizerdeutsch untergeschoben.
|
||||||
|
@Test("Schweizerdeutsch ist nicht Deutsch")
|
||||||
|
func swissGermanStandsAlone() {
|
||||||
|
#expect(!LanguageCode.matches("gsw", "de"))
|
||||||
|
#expect(LanguageCode.matches("gsw", "gsw"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Auch die anderen Doppelformen")
|
||||||
|
func otherDoubleForms() {
|
||||||
|
#expect(LanguageCode.matches("fre", "fra"))
|
||||||
|
#expect(LanguageCode.matches("dut", "nl"))
|
||||||
|
#expect(LanguageCode.matches("cze", "cs"))
|
||||||
|
#expect(LanguageCode.matches("chi", "zh"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ohne Angabe kein Treffer")
|
||||||
|
func missingIsNoMatch() {
|
||||||
|
#expect(!LanguageCode.matches(nil, "de"))
|
||||||
|
#expect(!LanguageCode.matches("de", nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("Wahl der Tonspur")
|
||||||
|
struct AudioTrackChooserTests {
|
||||||
|
private func spur(
|
||||||
|
_ index: Int,
|
||||||
|
_ sprache: String?,
|
||||||
|
title: String? = nil,
|
||||||
|
channels: Int = 2,
|
||||||
|
isDefault: Bool = false
|
||||||
|
) -> MediaStream {
|
||||||
|
MediaStream(
|
||||||
|
id: String(index),
|
||||||
|
kind: .audio,
|
||||||
|
codec: "aac",
|
||||||
|
language: sprache,
|
||||||
|
languageCode: sprache,
|
||||||
|
displayTitle: title,
|
||||||
|
channels: channels,
|
||||||
|
title: title,
|
||||||
|
isDefault: isDefault
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Der ZDF-Fall
|
||||||
|
|
||||||
|
/// Wörtlich der Ärger, wegen dem es den Typ gibt: die als deutsch
|
||||||
|
/// ausgezeichnete Spur ist die mit Audiodeskription, und die als englisch
|
||||||
|
/// ausgezeichnete ist in Wahrheit die deutsche ohne.
|
||||||
|
private var zdf: [MediaStream] {
|
||||||
|
[
|
||||||
|
spur(0, "ger", title: "Deutsch (Audiodeskription)", isDefault: true),
|
||||||
|
spur(1, "eng", title: "Deutsch"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Audiodeskription wird gemieden, auch wenn sie Standard ist")
|
||||||
|
func avoidsAudioDescription() {
|
||||||
|
let gewählt = AudioTrackChooser.choose(
|
||||||
|
from: zdf,
|
||||||
|
wish: .init(language: "de")
|
||||||
|
)
|
||||||
|
#expect(gewählt?.id == "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ohne das Meiden gewinnt die ausgezeichnete Sprache — der Beweis, dass
|
||||||
|
/// der Schalter tatsächlich das Ruder herumreißt und nicht nur dabeisteht.
|
||||||
|
@Test("Ohne das Meiden gewinnt das Kürzel")
|
||||||
|
func withoutAvoidanceTheCodeWins() {
|
||||||
|
let gewählt = AudioTrackChooser.choose(
|
||||||
|
from: zdf,
|
||||||
|
wish: .init(language: "de", avoidsAudioDescription: false)
|
||||||
|
)
|
||||||
|
#expect(gewählt?.id == "0")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Und wenn der Nutzer einmal gewählt hat, gilt das — auch gegen die
|
||||||
|
/// Sprache.
|
||||||
|
@Test("Die gemerkte Spur schlägt den Sprachwunsch")
|
||||||
|
func rememberedBeatsLanguage() {
|
||||||
|
let gemerkt = AudioTrackChooser.TrackSignature(zdf[1])
|
||||||
|
let gewählt = AudioTrackChooser.choose(
|
||||||
|
from: zdf,
|
||||||
|
wish: .init(language: "de", remembered: gemerkt, avoidsAudioDescription: false)
|
||||||
|
)
|
||||||
|
#expect(gewählt?.id == "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Die Rangliste
|
||||||
|
|
||||||
|
@Test("Fehlt die Wunschsprache, greift die Rangliste")
|
||||||
|
func fallbackListApplies() {
|
||||||
|
let spuren = [spur(0, "fre"), spur(1, "eng"), spur(2, "ita")]
|
||||||
|
let gewählt = AudioTrackChooser.choose(
|
||||||
|
from: spuren,
|
||||||
|
wish: .init(language: "de", fallbacks: ["gsw", "en", "fr"])
|
||||||
|
)
|
||||||
|
#expect(gewählt?.id == "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Die Reihenfolge der Rangliste zählt")
|
||||||
|
func fallbackOrderMatters() {
|
||||||
|
let spuren = [spur(0, "fre"), spur(1, "eng")]
|
||||||
|
let zuerstFranzösisch = AudioTrackChooser.choose(
|
||||||
|
from: spuren,
|
||||||
|
wish: .init(language: "de", fallbacks: ["fr", "en"])
|
||||||
|
)
|
||||||
|
#expect(zuerstFranzösisch?.id == "0")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Schweizerdeutsch lässt sich in die Rangliste stellen")
|
||||||
|
func swissGermanInTheList() {
|
||||||
|
let spuren = [spur(0, "eng"), spur(1, "gsw")]
|
||||||
|
let gewählt = AudioTrackChooser.choose(
|
||||||
|
from: spuren,
|
||||||
|
wish: .init(language: "de", fallbacks: ["gsw", "en"])
|
||||||
|
)
|
||||||
|
#expect(gewählt?.id == "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Der Boden darunter
|
||||||
|
|
||||||
|
@Test("Ohne jede Übereinstimmung kommt trotzdem eine Spur")
|
||||||
|
func alwaysAnswers() {
|
||||||
|
let spuren = [spur(0, "jpn"), spur(1, "kor", isDefault: true)]
|
||||||
|
let gewählt = AudioTrackChooser.choose(
|
||||||
|
from: spuren,
|
||||||
|
wish: .init(language: "de", fallbacks: ["en"])
|
||||||
|
)
|
||||||
|
// Die Standardspur — nachvollziehbar statt beliebig.
|
||||||
|
#expect(gewählt?.id == "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Gibt es die Sprache nur mit Audiodeskription, ist sie besser als nichts")
|
||||||
|
func audioDescriptionBeatsSilence() {
|
||||||
|
let spuren = [spur(0, "ger", title: "Deutsch (Audiodeskription)")]
|
||||||
|
let gewählt = AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))
|
||||||
|
#expect(gewählt?.id == "0")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ohne Tonspuren gibt es nichts zu wählen")
|
||||||
|
func noAudioNoChoice() {
|
||||||
|
let nurBild = [MediaStream(id: "0", kind: .video)]
|
||||||
|
#expect(AudioTrackChooser.choose(from: nurBild, wish: .init()) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Kommentarspuren werden gemieden")
|
||||||
|
func avoidsCommentary() {
|
||||||
|
let spuren = [
|
||||||
|
spur(0, "ger", title: "Audiokommentar des Regisseurs", isDefault: true),
|
||||||
|
spur(1, "ger", title: "Deutsch"),
|
||||||
|
]
|
||||||
|
let gewählt = AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))
|
||||||
|
#expect(gewählt?.id == "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// „AD" als eigenes Wort, nicht als Teil von „Broadcast".
|
||||||
|
@Test("AD erkennt nur das freistehende Wort")
|
||||||
|
func adOnlyAsWholeWord() {
|
||||||
|
let spuren = [
|
||||||
|
spur(0, "ger", title: "Deutsch Broadcast Mix"),
|
||||||
|
spur(1, "ger", title: "Deutsch AD"),
|
||||||
|
]
|
||||||
|
let gewählt = AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))
|
||||||
|
#expect(gewählt?.id == "0")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Mehr Kanäle gewinnen bei sonst gleichem Stand")
|
||||||
|
func moreChannelsWin() {
|
||||||
|
let spuren = [spur(0, "ger", title: "Deutsch", channels: 2),
|
||||||
|
spur(1, "ger", title: "Deutsch", channels: 6)]
|
||||||
|
let gewählt = AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))
|
||||||
|
#expect(gewählt?.id == "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zweimal dieselbe Datei muss zweimal dasselbe ergeben — sonst ist ein
|
||||||
|
/// Fehler nicht wiederholbar.
|
||||||
|
@Test("Gleichstand entscheidet die Nummer")
|
||||||
|
func tiesAreStable() {
|
||||||
|
let spuren = [spur(1, "ger", title: "Deutsch"), spur(0, "ger", title: "Deutsch")]
|
||||||
|
#expect(AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))?.id == "0")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user