Files
Flaro/Packages/FlaroKit/Sources/FlaroPlayback/Decision/StreamHealth.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

176 lines
6.9 KiB
Swift

import Foundation
/// Eine Probe vom laufenden Stream.
///
/// Alle Zeiten **seit Beginn dieser Wiedergabe**, nicht als Datum — dasselbe
/// wie beim `StallLedger`, und aus demselben Grund: eine Systemuhr, die sich
/// mitten im Film stellt, darf das Urteil nicht kippen.
public struct StreamSample: Sendable, Equatable {
public let at: Duration
/// Wie weit der Puffer vor der Abspielstelle reicht. `nil` heißt
/// „noch nichts geladen" — etwas anderes als ein leerer Puffer.
public let bufferedAhead: Duration?
public let isLikelyToKeepUp: Bool?
/// Der fortlaufende Zähler, nicht die Zahl seit der letzten Probe.
public let stallCount: Int
/// Auf welcher Sprosse gerade gespielt wird, in Kilobit je Sekunde.
public let indicatedBitrate: Int?
public init(
at: Duration,
bufferedAhead: Duration? = nil,
isLikelyToKeepUp: Bool? = nil,
stallCount: Int = 0,
indicatedBitrate: Int? = nil
) {
self.at = at
self.bufferedAhead = bufferedAhead
self.isLikelyToKeepUp = isLikelyToKeepUp
self.stallCount = stallCount
self.indicatedBitrate = indicatedBitrate
}
}
/// Wie es dem Stream geht.
public enum StreamCondition: Sendable, Equatable {
/// Trägt. Es darf wieder eine Stufe höher gehen.
case healthy
/// Wird knapp. Eine Stufe tiefer, bevor das Bild steht.
case strained
/// Trägt nicht. So tief wie möglich.
case failing
}
/// Beurteilt den Stream aus einer Folge von Proben.
///
/// **Warum als reiner Typ.** Kein AVFoundation, keine Uhr, kein Netz — Proben
/// hinein, Urteil heraus. Dieselbe Bauform wie `PlaybackDecider`, und aus
/// demselben Grund: eine Regelung, die man nur im laufenden Film beobachten
/// kann, ist nicht zu beurteilen. Hier lässt sich eine schlechte Zugfahrt als
/// Zahlenreihe hinschreiben und nachsehen, was sie auslöst.
///
/// **Welches Signal wann meldet** — das ist der eigentliche Inhalt:
///
/// - **Der Pufferverlauf ist der einzige echte Frühwarner.** Schrumpft er
/// schneller, als die Zeit vergeht, kommt weniger herein als hinausgeht.
/// Das sieht man zehn bis zwanzig Sekunden vor dem Rückler.
/// - `isLikelyToKeepUp` ist früh, aber grob: Apple legt es um, wenn es sich
/// schon sorgt. Als Bestätigung gut, als alleiniger Auslöser zu sprunghaft.
/// - Der Rücklerzähler meldet **zu spät zum Regeln** — das Bild stand bereits.
/// Er ist trotzdem unverzichtbar: an ihm misst sich hinterher, ob es besser
/// wurde.
/// - Die **gemessene** Bitrate steht hier bewusst nicht. Auf einer kleinen
/// Sprosse holt der Player wenig, ohne dass die Leitung schlecht wäre —
/// als Kriterium wäre sie irreführend.
///
/// **Absichtlich unsymmetrisch: schnell runter, langsam rauf.** Ein falscher
/// Schritt nach unten kostet Schärfe, ein falscher nach oben einen Rückler.
public struct StreamHealth: Sendable, Equatable {
/// Wie viel Puffer als knapp gilt.
public static let lowBuffer: Duration = .seconds(6)
/// Ab wann er als reichlich gilt.
public static let goodBuffer: Duration = .seconds(20)
private var samples: [StreamSample] = []
private(set) public var condition: StreamCondition = .healthy
/// Seit wann es ununterbrochen gut aussieht. `nil`, sobald es das nicht tut.
private var healthySince: Duration?
public init() {}
/// Nimmt eine Probe und gibt das aktuelle Urteil zurück.
@discardableResult
public mutating func record(_ sample: StreamSample) -> StreamCondition {
samples.append(sample)
samples.removeAll { $0.at < sample.at - .seconds(60) }
condition = judge(at: sample)
return condition
}
public mutating func removeAll() {
samples.removeAll()
healthySince = nil
condition = .healthy
}
// MARK: - Das Urteil
private mutating func judge(at now: StreamSample) -> StreamCondition {
// **Zuerst das Schlimmste.** Zwei Rückler in einer Minute heißen: es
// war kein Ausrutscher.
if stalls(within: .seconds(60), at: now) >= 2 {
healthySince = nil
return .failing
}
// Puffer auf null, während gespielt wird. `nil` zählt nicht — das
// heißt „noch nichts geladen" und ist der Zustand beim Start.
if let buffer = now.bufferedAhead, buffer == .zero {
healthySince = nil
return .failing
}
if isStrained(at: now) {
healthySince = nil
return .strained
}
// **Der Weg zurück ist der schmalste.** Reichlich Puffer allein
// genügt nicht: er muss eine halbe Minute lang reichlich geblieben
// sein, und in der letzten Minute darf es nicht gehakt haben. Sonst
// pendelt die Stufe im Takt der Aussetzer, und das ist schlimmer als
// eine Stufe zu niedrig zu bleiben.
guard let buffer = now.bufferedAhead, buffer >= Self.goodBuffer else {
healthySince = nil
return condition == .healthy ? .healthy : condition
}
let seit = healthySince ?? now.at
healthySince = seit
let langGenug = now.at - seit >= .seconds(30)
let ruhig = stalls(within: .seconds(60), at: now) == 0
guard langGenug, ruhig else {
return condition == .healthy ? .healthy : condition
}
return .healthy
}
private func isStrained(at now: StreamSample) -> Bool {
// Knapp **und** fallend. Ein kleiner Puffer, der stabil bleibt, ist
// kein Alarm — er ist nur klein.
if let buffer = now.bufferedAhead, buffer < Self.lowBuffer, isFalling(at: now) {
return true
}
// Apples eigene Sorge, aber erst wenn sie anhält: ein einzelner
// Ausschlag kommt bei jedem Sprung vor.
let letzte = samples.filter { $0.at >= now.at - .seconds(3) }
if letzte.count >= 3, letzte.allSatisfy({ $0.isLikelyToKeepUp == false }) {
return true
}
return false
}
/// Ob der Puffer über die letzten Sekunden hinweg schrumpft.
private func isFalling(at now: StreamSample) -> Bool {
let fenster = samples.filter { $0.at >= now.at - .seconds(5) }
guard fenster.count >= 3,
let ältester = fenster.first?.bufferedAhead,
let jüngster = fenster.last?.bufferedAhead
else { return false }
return jüngster < ältester
}
/// Wie viele Rückler im Zeitfenster dazugekommen sind.
///
/// Aus der **Differenz** des fortlaufenden Zählers, nicht aus einer
/// eigenen Zählung: der Zähler kommt vom System und weiß auch von
/// Rücklern zwischen zwei Proben.
private func stalls(within window: Duration, at now: StreamSample) -> Int {
let fenster = samples.filter { $0.at >= now.at - window }
guard let erster = fenster.first else { return 0 }
return max(0, now.stallCount - erster.stallCount)
}
}