Files
Flaro/Packages/HaloKit/Sources/HaloDownloads/DownloadRate.swift
T
ScarriffleandClaude Opus 5 4b9a1e7b67 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>
2026-10-03 11:12:32 +02:00

103 lines
4.1 KiB
Swift

import Foundation
/// Wie schnell ein Download gerade läuft — und wie lange er noch braucht.
///
/// **Ein Wertetyp mit Uhr von außen.** Die Rückmeldungen der Übertragung
/// kommen mehrmals je Sekunde; was daraus eine Zahl macht, ist reine Rechnung
/// und gehört nicht in den Koordinator. Die Zeitpunkte kommen als Argument
/// herein, damit sich eine Übertragung ohne echte Uhr nachstellen lässt — eine
/// Rate, die man nur im Betrieb beurteilen kann, ist keine prüfbare Zahl.
///
/// **Warum geglättet.** Zwischen zwei Rückmeldungen schwankt die rohe
/// Differenz um ein Vielfaches: ein Puffer läuft voll, der Funk hat ein Loch,
/// das System hält die Hintergrundsitzung kurz an. Ungeglättet stünde dort
/// eine Zahl, die zehnmal je Sekunde springt — und eine springende Zahl ist
/// keine Auskunft, sondern Unruhe.
public struct DownloadRate: Sendable, Equatable {
/// Wie stark ein neuer Messwert gegenüber dem bisherigen zählt.
///
/// Ein Drittel: schnell genug, dass ein echter Einbruch binnen weniger
/// Sekunden ankommt, träge genug, dass einzelne Ausreißer untergehen.
private static let weight = 1.0 / 3.0
/// Kürzere Abstände werden nicht gemessen.
///
/// Über fünfzig Millisekunden gerechnet entscheidet der Zufall, wann das
/// System den Puffer leert — nicht die Leitung.
static let minimumInterval: Duration = .milliseconds(500)
/// Ab dieser Lücke beginnt die Messung von vorn.
///
/// So lange ohne eine einzige Rückmeldung heißt nicht „langsam", sondern
/// „stand still": die App war angehalten, das Gerät gesperrt, das Netz
/// weg. Diese Pause in die Rate einzurechnen ergäbe eine Zahl nahe null
/// und eine Restzeit von Stunden.
static let maximumInterval: Duration = .seconds(10)
/// Ein Messpunkt: wie viel war wann da.
///
/// Als Typ und nicht als Tupel — ein Tupel kann `Equatable` nicht erben,
/// und ohne das wäre der ganze Wertetyp nicht vergleichbar.
private struct Anchor: Equatable {
var bytes: Int64
var at: ContinuousClock.Instant
}
/// Der letzte Messpunkt, gegen den gerechnet wird.
private var anchor: Anchor?
private var smoothed: Double?
public init() {}
/// Bytes je Sekunde — `nil`, solange nichts gemessen wurde.
///
/// **Nicht null.** Null wäre eine Auskunft („es geht nichts durch"), und
/// die stimmt in der ersten halben Sekunde nie.
public var bytesPerSecond: Double? { smoothed }
/// Nimmt den Stand der Übertragung entgegen.
public mutating func record(bytes: Int64, at now: ContinuousClock.Instant) {
guard let anchor else {
self.anchor = Anchor(bytes: bytes, at: now)
return
}
let elapsed = now - anchor.at
guard elapsed >= Self.minimumInterval else { return }
defer { self.anchor = Anchor(bytes: bytes, at: now) }
// Rückwärts heißt: eine neue Aufgabe fängt von vorn an. Dann ist jede
// bisherige Messung ungültig — und eine negative Rate wäre Unsinn.
guard bytes >= anchor.bytes, elapsed <= Self.maximumInterval else {
smoothed = nil
return
}
let seconds = elapsed.inSeconds
guard seconds > 0 else { return }
let gemessen = Double(bytes - anchor.bytes) / seconds
smoothed = smoothed.map { $0 + (gemessen - $0) * Self.weight } ?? gemessen
}
/// Wie lange es bei dieser Rate noch dauert.
///
/// `nil`, solange keine Rate feststeht oder die Gesamtgröße unbekannt ist.
/// Eine Restzeit ohne Rate wäre geraten, und geraten sieht man ihr nicht
/// an.
public func remaining(received: Int64, total: Int64) -> Duration? {
guard let smoothed, smoothed > 0, total > received else { return nil }
return .seconds(Double(total - received) / smoothed)
}
}
extension Duration {
/// Die Dauer als Sekunden.
///
/// `components` liefert ganze Sekunden und Attosekunden getrennt; für eine
/// Division braucht es beides in einer Zahl.
var inSeconds: Double {
Double(components.seconds) + Double(components.attoseconds) / 1e18
}
}