Files
Flaro/Packages/FlixyKit/Sources/FlixyPlayback/Decision/AudioTrackChooser.swift
T
ScarriffleandClaude Opus 5 983015b9ad Die Sprache lässt sich jetzt auch je Serie und je Mediathek sagen
Das Regelwerk stand schon und wurde abgefragt — es gab nur keinen Ort, an
dem man etwas hineinschreiben konnte. Jetzt steht die Wahl auf der
Serienseite und im Filter-Sheet der Mediathek, beide über denselben
Auswahlknopf: zwei mit demselben Inhalt liefen unweigerlich auseinander.

Bei der Mediathek in einem eigenen Abschnitt, nicht zwischen den Filtern.
Ein Filter schränkt ein, was zu sehen ist; das hier bestimmt, was zu hören
ist — dazwischengestellt läse es sich als „nur Titel mit deutscher Tonspur
zeigen".

Die Rangfolge Serie → Mediathek → allgemein ist dabei von den Einstellungen
zu `AudioTrackChooser.resolve` gewandert. Sie ist eine Regel, keine Ablage,
und im App-Ziel gibt es kein Unit-Test-Ziel — dort liesse sie sich nicht
durchspielen. Sechs Tests decken sie jetzt ab, darunter der Fall, an dem
man sonst lange sucht: eine leere Zeichenkette steht in den Einstellungen
für „soll der Server entscheiden" und darf nicht als Sprache gelten, sonst
schaltet sie die Ebene darüber stillschweigend ab.

Nebenbei bekommt das Filter-Sheet die Mediathek selbst statt nur ihrer Art.
Die Genres wurden vorher über irgendeine Mediathek dieser Art nachgeschlagen
— bei zwei Filmmediatheken also über die falsche.

Und der Puffer steht auf einer Minute statt auf 25 Sekunden. Es ist ein
Mindestwunsch, keine Obergrenze; der harte Deckel liegt ohnehin woanders,
nämlich darin, wie weit der Transcoder schon produziert hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 17:12:17 +02:00

217 lines
8.7 KiB
Swift

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 }
}
}