Files
Flaro/Packages/FlaroKit/Sources/FlaroPlayback/Decision/AudioTrackChooser.swift
T
ScarriffleandClaude Opus 5 a7cf6c5def Halo heisst ab hier Flaro — die Marke stand im Weg
Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine
eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt
so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner
Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging
daneben.

**Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den
deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche
Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in
Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store
der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden
frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler.

Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen
ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim
letzten Mal stattfand.

---

2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der
Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`,
`Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen
`FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und
das Image.

**Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen
Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes
Ersetzen hätte den sonst zerlegt.

`docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte
Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das
Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden
in die Irre führt.

**Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag:
`com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat
jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe.

308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS
bauen, das Image baut, und ein frischer Container läuft durch Einrichtung
und Anmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 19:38:27 +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 }
}
}