diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/AudioTrackChooser.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/AudioTrackChooser.swift new file mode 100644 index 0000000..8cd9b62 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/AudioTrackChooser.swift @@ -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 } + } +} diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/LanguageCode.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/LanguageCode.swift new file mode 100644 index 0000000..ab4f43e --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/LanguageCode.swift @@ -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 + } +} diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift index 1ccd7b2..ec87953 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift @@ -159,7 +159,11 @@ extension JellyfinMediaStream { displayTitle: displayTitle, channels: channels, isSelected: isDefault ?? false, - isForced: isForced ?? false + isForced: isForced ?? false, + title: title, + isDefault: isDefault ?? false, + isHearingImpaired: isHearingImpaired ?? false, + isOriginal: isOriginal ?? false ) } } diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaVersion.swift b/Packages/FlixyKit/Sources/MediaKit/MediaVersion.swift index 5ebac2d..68830d4 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaVersion.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaVersion.swift @@ -174,6 +174,19 @@ public struct MediaStream: Sendable, Equatable, Identifiable { public let isSelected: Bool /// Untertitel, die auch bei abgeschalteten Untertiteln erscheinen sollen. 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( id: String, @@ -184,7 +197,11 @@ public struct MediaStream: Sendable, Equatable, Identifiable { displayTitle: String? = nil, channels: Int? = nil, 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.kind = kind @@ -195,8 +212,15 @@ public struct MediaStream: Sendable, Equatable, Identifiable { self.channels = channels self.isSelected = isSelected 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. /// /// „Spur 3" hilft niemandem bei der Wahl; Sprache und Codec schon. diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/AudioTrackChooserTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/AudioTrackChooserTests.swift new file mode 100644 index 0000000..b520231 --- /dev/null +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/AudioTrackChooserTests.swift @@ -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") + } +}