Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,216 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user