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 } } /// Drei Ebenen, eine Antwort. /// /// Serie → Mediathek → allgemein /// /// Gefragt wird von unten nach oben; die erste Antwort gilt. Bei Filmen /// gibt es die Serienebene nicht, dort endet es bei der Mediathek. /// /// **Warum die Rangfolge hier steht und nicht bei den Einstellungen.** /// Weil sie eine Regel ist, keine Ablage. Im App-Ziel liesse sie sich /// nicht mit einer Wertefolge durchspielen — hier schon, und das ist der /// Unterschied zwischen einer geprüften und einer geglaubten Rangfolge. /// /// Eine leere Zeichenkette zählt als „nichts gesagt": in den Einstellungen /// steht sie für „soll der Server entscheiden", und das ist keine Sprache, /// sondern das Ausbleiben einer Antwort. public static func resolve( series: String?, library: String?, general: String?, fallbacks: [String] = [], remembered: TrackSignature? = nil, avoidsAudioDescription: Bool = true ) -> Wish { func gesagt(_ wert: String?) -> String? { guard let wert, !wert.trimmingCharacters(in: .whitespaces).isEmpty else { return nil } return wert } return Wish( language: gesagt(series) ?? gesagt(library) ?? gesagt(general), fallbacks: fallbacks, remembered: remembered, 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 } } }