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:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
@@ -0,0 +1,140 @@
import Foundation
/// Wann **hinauf** gegangen wird — und wann der teure Weg dafür erlaubt ist.
///
/// **Die Falle, um die es hier geht.** Damit ein Film zügig anläuft, wird mit
/// einer niedrigen Stufe begonnen. Der Server baut seine Leiter aber aus
/// genau dieser Bestellung: wer mit drei Megabit anfängt, bekommt eine
/// Leiter, deren oberste Sprosse drei Megabit ist. Das billige Hinaufschalten
/// über `preferredPeakBitRate` bewegt sich **innerhalb** dieser Leiter — es
/// käme also nie darüber hinaus, und aus dem schnellen Start würde ein Film,
/// der den ganzen Abend in 720p bleibt.
///
/// Höher geht es nur mit einem Neuaufbau: neu aushandeln, mit einer grösseren
/// Grenze. Der kostet Sekunden mit schwarzem Bild — deshalb dieselbe Bauform
/// wie `DowngradeRule`: eine Liste von Bedingungen, die **alle** gelten
/// müssen, kein Schwellwert.
///
/// **Und deutlich zurückhaltender als das Absenken.** Ein zu spätes Absenken
/// bedeutet Ruckeln; ein zu spätes Hinaufschalten bedeutet ein etwas
/// weicheres Bild. Das erste ist ein Fehler, das zweite eine Geschmacksfrage.
public enum UpgradeRule {
/// Wie lange es ruhig laufen muss, bevor überhaupt gefragt wird.
///
/// Lang genug, dass es kein anfängliches Aufatmen ist: der Puffer füllt
/// sich nach dem Start immer, und das sieht zwanzig Sekunden lang nach
/// einer hervorragenden Leitung aus.
public static let settleIn: Duration = .seconds(45)
/// Sperrfrist zwischen zwei Neuaufbauten nach oben.
public static let cooldown: Duration = .seconds(150)
/// Wie lange nach einem Rückler gar nicht hinaufgegangen wird.
///
/// Wer gerade gestockt hat, hat keine Reserve — und ein Neuaufbau nach
/// oben wäre dann die zweite Unterbrechung binnen einer Minute.
public static let afterStall: Duration = .seconds(90)
/// Wie viel Vorrat dasein muss.
///
/// Nicht „es läuft gerade", sondern „es liegt etwas auf der hohen Kante".
/// Nur das trägt einen Neuaufbau.
public static let bufferNeeded: Duration = .seconds(20)
/// Was zum Zeitpunkt der Frage gilt.
public struct Facts: Sendable, Equatable {
/// Eine ausdrücklich gewählte Stufe ist eine Ansage und wird nicht
/// überstimmt — auch nicht nach oben.
public let isAutomatic: Bool
public let condition: StreamCondition
/// Ob die Leiter noch eine Sprosse nach oben hat. Solange ja, ist der
/// **billige** Weg der richtige.
public let hasHigherRung: Bool
/// Ob die eingestellte Obergrenze überhaupt mehr zuliesse.
public let ceilingAllowsMore: Bool
/// Wie viel Vorrat vor der Abspielstelle liegt.
public let bufferedAhead: Duration?
/// Wie lange schon gespielt wird.
public let playedFor: Duration
/// Wie lange der letzte Rückler her ist. `nil` heisst: keiner gewesen.
public let sinceLastStall: Duration?
/// Wie lange der letzte Sprung her ist.
public let sinceLastSeek: Duration?
/// Wie lange der letzte Neuaufbau nach oben her ist.
public let sinceLastUpgrade: Duration?
/// Wie viele es in dieser Wiedergabe schon gab.
public let used: Int
/// Wie viele es höchstens geben darf.
public let budget: Int
public init(
isAutomatic: Bool,
condition: StreamCondition,
hasHigherRung: Bool,
ceilingAllowsMore: Bool,
bufferedAhead: Duration?,
playedFor: Duration,
sinceLastStall: Duration? = nil,
sinceLastSeek: Duration? = nil,
sinceLastUpgrade: Duration? = nil,
used: Int = 0,
budget: Int = 2
) {
self.isAutomatic = isAutomatic
self.condition = condition
self.hasHigherRung = hasHigherRung
self.ceilingAllowsMore = ceilingAllowsMore
self.bufferedAhead = bufferedAhead
self.playedFor = playedFor
self.sinceLastStall = sinceLastStall
self.sinceLastSeek = sinceLastSeek
self.sinceLastUpgrade = sinceLastUpgrade
self.used = used
self.budget = budget
}
}
/// Warum **nicht** hinaufgegangen wird.
///
/// Als Aufzählung statt als `Bool`, aus demselben Grund wie beim
/// Absenken: „es läuft gut und es passiert nichts" ist ohne Begründung
/// von einem Fehler nicht zu unterscheiden.
public enum Verdict: Sendable, Equatable {
case rebuildHigher
/// Eine gewählte Stufe wird nicht überstimmt.
case notAutomatic
/// Die Leiter hat noch eine Sprosse — der billige Weg genügt.
case cheaperPathRemains
/// Die Einstellung lässt nicht mehr zu. Kein Fehler, eine Ansage.
case atCeiling
/// Es läuft nicht ruhig genug.
case notCalmEnough
/// Zu früh nach Start, Sprung oder Rückler.
case tooEarly
/// Sperrfrist läuft noch.
case cooling
/// Verbraucht.
case budgetSpent
}
public static func verdict(_ facts: Facts) -> Verdict {
guard facts.isAutomatic else { return .notAutomatic }
guard facts.ceilingAllowsMore else { return .atCeiling }
// **Erst die Leiter ausreizen.** Solange es eine Sprosse höher gibt,
// kostet das Hinaufgehen kein schwarzes Bild — und wäre ein
// Neuaufbau dafür Verschwendung.
if facts.hasHigherRung { return .cheaperPathRemains }
guard facts.condition == .healthy else { return .notCalmEnough }
guard let vorrat = facts.bufferedAhead, vorrat >= bufferNeeded else {
return .notCalmEnough
}
guard facts.playedFor >= settleIn else { return .tooEarly }
if let seit = facts.sinceLastSeek, seit < DowngradeRule.afterSeek { return .tooEarly }
if let seit = facts.sinceLastStall, seit < afterStall { return .tooEarly }
if let seit = facts.sinceLastUpgrade, seit < cooldown { return .cooling }
guard facts.used < facts.budget else { return .budgetSpent }
return .rebuildHigher
}
}