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,249 @@
import Foundation
import Network
import OSLog
/// Ein winziger HTTP-Server, von dem AVPlayer seinen Film holt.
///
/// **Warum überhaupt ein Server.** Apple lässt uns bei HLS nicht zwischen
/// Player und Netz: auf eine Anfrage nach einem Schnipsel nimmt AVPlayer von
/// uns nur eine Weiterleitung an, keine Daten. Der einzige Weg, ihm etwas
/// Eigenes zu liefern, ist, die Gegenstelle **zu sein**. Dann ist es keine
/// Einmischung mehr, sondern eine gewöhnliche Anfrage an eine gewöhnliche
/// Adresse — die zufällig auf demselben Gerät liegt.
///
/// **Nur auf diesem Gerät.** Der Lauscher hängt ausdrücklich an `127.0.0.1`,
/// nicht an allen Schnittstellen. Ein Server, der den Film ins Netz stellt,
/// wäre das Letzte, was eine Mediathek-App tun sollte — und in einem offenen
/// WLAN eine Einladung.
///
/// Der Port kommt vom System. Ein fester wäre beim zweiten Start belegt, und
/// zwei Wiedergaben gleichzeitig — Kachel und Vollbild beim Umschalten — gäbe
/// es dann nie zweimal.
public actor LocalStreamServer {
/// Der Pfad, unter dem Schnipsel ausgeliefert werden.
private static let segmentPrefix = "/s/"
/// Der Pfad der Abspielliste selbst.
private static let playlistPath = "/playlist.m3u8"
/// Der Pfad einer Stufe, die eine Ebene tiefer liegt.
///
/// Die Endung muss stimmen: AVPlayer entscheidet am Namen, ob er eine
/// Abspielliste oder Bild erwartet.
private static let levelPrefix = "/level.m3u8"
/// Wie eine Adresse aus der Abspielliste beim Server heisst.
///
/// Der ursprüngliche Pfad wandert dabei in die Abfrage, damit Schrägstriche
/// und Punkte darin nichts anrichten.
/// Wie eine Stufe aus der Stufenliste beim Server heisst.
///
/// Vollständig, nicht relativ: eine Stufe liegt beim Server unter einem
/// ganz anderen Pfad als bei uns, und relativ aufgelöst zeigte sie ins
/// Leere.
/// - Parameter prefetch: ob dahinter Bild liegt. Bei einer Nebenspur —
/// Untertitel oder zusätzlicher Ton — steht es auf `false`, sonst lüde
/// der Vorrat Untertitel statt Bild, und der Film bliebe stehen,
/// während die Anzeige reichlich Vorrat meldet.
public nonisolated static func localPlaylistPath(
for absolute: String,
prefetch: Bool
) -> String {
var teile = URLComponents()
teile.path = levelPrefix
teile.queryItems = [
URLQueryItem(name: "u", value: absolute),
URLQueryItem(name: "v", value: prefetch ? "1" : "0"),
]
return teile.string ?? levelPrefix
}
/// Wie ein Schnipsel beim Server heisst.
///
/// Die **vollständige** Adresse wandert in die Abfrage. Eine relative
/// gälte je nach Liste gegen eine andere Grundadresse — die
/// Untertitelspur liegt woanders als das Bild —, und wer das erst beim
/// Abholen auflöst, löst es irgendwann gegen die falsche auf.
public nonisolated static func localPath(for absolute: String) -> String {
var teile = URLComponents()
teile.path = segmentPrefix
teile.queryItems = [URLQueryItem(name: "u", value: absolute)]
return teile.string ?? segmentPrefix
}
private let cache: StreamCache
private let logger = Logger(subsystem: "app.halo", category: "LocalStreamServer")
private var listener: NWListener?
private var port: UInt16?
private var playlist: String?
public init(cache: StreamCache) {
self.cache = cache
}
// MARK: - Starten
/// Startet den Server und gibt die Adresse zurück, die der Player bekommt.
///
/// Wirft, wenn der Lauscher nicht hochkommt — dann muss der Aufrufer die
/// ursprüngliche Adresse verwenden. **Kein Ersatz für Wiedergabe:** lieber
/// ohne Vorrat spielen als gar nicht.
public func start(upstream: URL, headers: [String: String] = [:]) async throws -> URL {
playlist = try await cache.start(upstream: upstream, headers: headers)
let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any)
let lauscher = try NWListener(using: parameters)
lauscher.newConnectionHandler = { [weak self] verbindung in
Task { await self?.accept(verbindung) }
}
// Der Zustandsmelder kommt mehrfach und aus fremdem Zusammenhang.
// Eine Fortsetzung zweimal aufzulösen bringt das Programm zum
// Anhalten — deshalb ein Riegel, der das ausschliesst.
let vergeben: UInt16 = try await withCheckedThrowingContinuation { fortsetzen in
let riegel = OneShot()
lauscher.stateUpdateHandler = { zustand in
switch zustand {
case .ready:
guard riegel.claim() else { return }
fortsetzen.resume(returning: lauscher.port?.rawValue ?? 0)
case .failed(let fehler):
guard riegel.claim() else { return }
fortsetzen.resume(throwing: fehler)
default:
break
}
}
lauscher.start(queue: .global(qos: .userInitiated))
}
listener = lauscher
port = vergeben
guard vergeben > 0,
let adresse = URL(string: "http://127.0.0.1:\(vergeben)\(Self.playlistPath)")
else { throw URLError(.cannotConnectToHost) }
return adresse
}
public func stop() {
listener?.cancel()
listener = nil
Task { await cache.teardown() }
}
// MARK: - Bedienen
private func accept(_ verbindung: NWConnection) {
verbindung.start(queue: .global(qos: .userInitiated))
receive(on: verbindung, buffer: Data())
}
private nonisolated func receive(on verbindung: NWConnection, buffer: Data) {
verbindung.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1_024) {
[weak self] daten, _, abgeschlossen, fehler in
guard let self else { return }
var gesammelt = buffer
if let daten { gesammelt.append(daten) }
// Der Kopf endet mit einer Leerzeile. Bis dahin sammeln — eine
// Anfrage kann auf mehrere Pakete verteilt ankommen, und wer das
// nicht abwartet, liest eine halbe Zeile als ganze Anfrage.
guard let kopfEnde = gesammelt.range(of: Data("\r\n\r\n".utf8)) else {
if fehler == nil, !abgeschlossen {
self.receive(on: verbindung, buffer: gesammelt)
} else {
verbindung.cancel()
}
return
}
let kopf = String(decoding: gesammelt[..<kopfEnde.lowerBound], as: UTF8.self)
Task { await self.respond(to: kopf, on: verbindung) }
}
}
private func respond(to kopf: String, on verbindung: NWConnection) async {
guard let anfrage = kopf.components(separatedBy: "\r\n").first,
let pfadTeil = anfrage.components(separatedBy: " ").dropFirst().first
else { return finish(verbindung, status: 400, body: Data()) }
if pfadTeil.hasPrefix(Self.levelPrefix) {
guard let teile = URLComponents(string: pfadTeil),
let adresse = teile.queryItems?.first(where: { $0.name == "u" })?.value,
let inhalt = await cache.playlist(
at: adresse,
prefetch: teile.queryItems?.first { $0.name == "v" }?.value != "0"
)
else { return finish(verbindung, status: 404, body: Data()) }
return finish(
verbindung, status: 200, body: Data(inhalt.utf8),
type: "application/vnd.apple.mpegurl"
)
}
if pfadTeil.hasPrefix(Self.playlistPath) {
let inhalt = Data((playlist ?? "").utf8)
return finish(
verbindung, status: 200, body: inhalt,
type: "application/vnd.apple.mpegurl"
)
}
guard pfadTeil.hasPrefix(Self.segmentPrefix),
let teile = URLComponents(string: pfadTeil),
let adresse = teile.queryItems?.first(where: { $0.name == "u" })?.value
else { return finish(verbindung, status: 404, body: Data()) }
guard let schnipsel = await cache.data(for: adresse) else {
return finish(verbindung, status: 504, body: Data())
}
// **Die Sorte, die wirklich ankam.** Hier stand fest „video/mp4", für
// jeden Schnipsel. Ein WebVTT-Körper, so angekündigt, geht bei
// AVFoundation an den falschen Parser und ergibt lautlos keine
// Einblendung — die Spur steht im Blatt, ist ausgewählt, und bleibt
// leer. Genau daran lag es.
finish(verbindung, status: 200, body: schnipsel.data, type: schnipsel.contentType)
}
private nonisolated func finish(
_ verbindung: NWConnection,
status: Int,
body: Data,
type: String = "application/octet-stream"
) {
let kopf = """
HTTP/1.1 \(status) \(status == 200 ? "OK" : "Error")\r
Content-Type: \(type)\r
Content-Length: \(body.count)\r
Accept-Ranges: none\r
Connection: close\r
\r
"""
var antwort = Data(kopf.utf8)
antwort.append(body)
verbindung.send(content: antwort, completion: .contentProcessed { _ in
verbindung.cancel()
})
}
}
/// Ein Riegel, der genau einmal aufgeht.
///
/// Für Rückrufe, die mehrfach kommen, aber nur einmal beantwortet werden
/// dürfen. Eine `var` im Abschluss täte es nicht: sie wird aus fremdem
/// Zusammenhang berührt, und Swift lässt das zu Recht nicht durchgehen.
private final class OneShot: @unchecked Sendable {
private let lock = NSLock()
private var used = false
func claim() -> Bool {
lock.withLock {
guard !used else { return false }
used = true
return true
}
}
}
@@ -0,0 +1,155 @@
import Foundation
/// Eine HLS-Abspielliste — gelesen und umgeschrieben.
///
/// **Wozu.** Damit ein Vorratsspeicher zwischen Player und Server passen
/// kann, muss er zweierlei können: wissen, welche Schnipsel es gibt und in
/// welcher Reihenfolge, und dem Player eine Liste geben, die auf ihn selbst
/// zeigt statt auf den Server. Beides ist reine Textarbeit an einer Datei mit
/// einem sehr einfachen Aufbau.
///
/// **Warum als eigener Typ ohne Netz.** Weil hier ein Fehler besonders teuer
/// wäre: eine falsch umgeschriebene Liste ergibt entweder ein schwarzes Bild
/// oder — schlimmer — eine, die stellenweise am Speicher vorbeigeht und nur
/// manchmal hakt. So etwas ist im laufenden Film nicht zu finden. Mit einer
/// Zeichenkette hinein und einer heraus schon.
///
/// Gelesen wird nur, was gebraucht wird: die Zeilen, die auf Dateien zeigen.
/// Alles andere — Verschlüsselung, Zeitmarken, Untertitelspuren — bleibt
/// unangetastet stehen. Eine Abspielliste, die wir nicht verstehen, soll
/// hindurchgehen, nicht verstümmelt werden.
public struct MediaPlaylist: Sendable, Equatable {
/// Die Zeilen, die auf eine Datei zeigen — in der Reihenfolge der Liste.
public let segments: [String]
/// Ob es eine Liste von Stufen ist statt einer von Schnipseln.
///
/// Die beiden sehen sich ähnlich und meinen Verschiedenes: eine
/// Master-Liste zählt Qualitätsstufen auf, eine Medienliste die Schnipsel
/// einer Stufe. Wer das verwechselt, lädt Abspiellisten vor statt Bild.
public let isMaster: Bool
private let lines: [String]
public init(_ text: String) {
let zeilen = text.components(separatedBy: .newlines)
self.lines = zeilen
var gefunden: [String] = []
var master = false
var nächsteIstStufe = false
for zeile in zeilen {
let getrimmt = zeile.trimmingCharacters(in: .whitespaces)
if getrimmt.hasPrefix("#EXT-X-STREAM-INF") {
master = true
nächsteIstStufe = true
continue
}
guard !getrimmt.isEmpty else { continue }
// Kommentare und Anweisungen beginnen mit `#`. Alles andere ist
// eine Adresse.
guard !getrimmt.hasPrefix("#") else { continue }
if nächsteIstStufe {
nächsteIstStufe = false
// Bei einer Master-Liste zeigen die Zeilen auf weitere
// Listen, nicht auf Bild. Sie zählen trotzdem als Adresse.
}
gefunden.append(getrimmt)
}
self.segments = gefunden
self.isMaster = master
}
/// Was für eine Adresse an einer Stelle steht.
///
/// **Warum das jemanden interessiert.** Eine Abspielliste enthält
/// viererlei Adressen, und sie wollen Verschiedenes: eine Qualitätsstufe
/// führt zu weiteren Schnipseln und soll vorgeladen werden, eine
/// Untertitelspur führt zu einer eigenen Liste und soll es **nicht** —
/// sonst lädt der Vorrat Untertitel statt Bild.
public enum Reference: Sendable, Equatable {
/// Eine Qualitätsstufe — dahinter liegt das Bild.
case level
/// Eine Nebenspur: Untertitel oder zusätzlicher Ton.
case rendition
/// Das Kopfstück einer fragmentierten MP4.
case map
/// Ein Schnipsel.
case segment
}
/// Schreibt jede Adresse um, ohne sonst etwas anzufassen.
///
/// Die Reihenfolge bleibt, die Anweisungen bleiben, die Leerzeilen
/// bleiben. Nur wo eine Adresse stand, steht danach eine andere.
///
/// **Auch die Adressen in Anweisungen.** Zwei Anweisungen tragen eine:
/// `EXT-X-MAP` das Kopfstück und `EXT-X-MEDIA` die Nebenspuren. Beide
/// stehen zu lassen ergibt eine Liste, die halb auf uns und halb ins
/// Leere zeigt — und das endet mit „404", sobald jemand Untertitel
/// einschaltet.
public func rewritingReferences(_ transform: (String, Reference) -> String) -> String {
var ergebnis: [String] = []
ergebnis.reserveCapacity(lines.count)
var nächsteIstStufe = false
for zeile in lines {
let getrimmt = zeile.trimmingCharacters(in: .whitespaces)
if getrimmt.hasPrefix("#EXT-X-STREAM-INF") {
nächsteIstStufe = true
ergebnis.append(zeile)
} else if getrimmt.hasPrefix("#EXT-X-MEDIA:") {
ergebnis.append(Self.rewritingURI(in: zeile) { transform($0, .rendition) })
} else if getrimmt.hasPrefix("#EXT-X-MAP:") {
ergebnis.append(Self.rewritingURI(in: zeile) { transform($0, .map) })
} else if getrimmt.isEmpty || getrimmt.hasPrefix("#") {
ergebnis.append(zeile)
} else {
ergebnis.append(transform(getrimmt, nächsteIstStufe ? .level : .segment))
nächsteIstStufe = false
}
}
return ergebnis.joined(separator: "\n")
}
/// Tauscht die Adresse in `URI="…"` aus, sonst nichts.
private static func rewritingURI(
in zeile: String,
_ transform: (String) -> String
) -> String {
let marke = "URI=\""
guard let start = zeile.range(of: marke),
let ende = zeile[start.upperBound...].firstIndex(of: "\"")
else { return zeile }
let adresse = String(zeile[start.upperBound..<ende])
return zeile.replacingCharacters(in: start.upperBound..<ende, with: transform(adresse))
}
/// Ob die Liste vollständig ist — dann kommen keine Schnipsel mehr dazu.
///
/// Bei einem laufenden Sender fehlt diese Marke, und die Liste muss
/// wiederholt geholt werden. Bei einem Film steht sie am Ende, und dann
/// weiss der Vorratsspeicher, dass er die Liste kennt.
public var isComplete: Bool {
lines.contains { $0.trimmingCharacters(in: .whitespaces) == "#EXT-X-ENDLIST" }
}
/// Wie lang die Schnipsel dauern — für die Frage, wie viele einem
/// Vorsprung von N Sekunden entsprechen.
public var targetDuration: Duration? {
for zeile in lines {
let getrimmt = zeile.trimmingCharacters(in: .whitespaces)
guard getrimmt.hasPrefix("#EXT-X-TARGETDURATION:") else { continue }
let wert = getrimmt.dropFirst("#EXT-X-TARGETDURATION:".count)
guard let sekunden = Double(wert.trimmingCharacters(in: .whitespaces)),
sekunden > 0
else { return nil }
return .seconds(sekunden)
}
return nil
}
}
@@ -0,0 +1,309 @@
import Foundation
import OSLog
/// Holt die Schnipsel eines Streams, bevor der Player sie verlangt.
///
/// **Warum es das gibt.** AVPlayer entscheidet selbst, wie weit er puffert,
/// und bei HLS lässt Apple uns nicht dazwischen. Wir müssen aber auch nicht:
/// steht zwischen Player und Server ein eigener Speicher, kann der so weit
/// vorauslaufen, wie er will. Der Player holt dann von der Platte, und ob die
/// Leitung gerade steht, merkt er nicht.
///
/// Genau das ist der Unterschied zwischen „Puffer" und „Vorrat": ein Puffer
/// ist das, was der Player im Arbeitsspeicher hält, und den bestimmt Apple.
/// Der Vorrat hier ist das, was **wir** schon haben, und den bestimmen wir.
///
/// **Streng der Reihe nach.** Der Server erzeugt die Schnipsel gerade erst.
/// Wer Nummer 40 verlangt, während er bei 12 ist, sieht für Jellyfin aus wie
/// jemand, der gesprungen ist — und dann beginnt das Umrechnen von vorn, an
/// der falschen Stelle. Deshalb wird nie übersprungen, sondern nur zügiger
/// geholt, als abgespielt wird.
///
/// **Der Vorsprung ist begrenzt**, und zwar in Sekunden Film, nicht in
/// Schnipseln: die Zahl soll dasselbe bedeuten, egal wie der Server schneidet.
public actor StreamCache {
/// Ein Schnipsel — Daten **und** wofür sie sich ausgeben.
public struct Segment: Sendable {
public let data: Data
public let contentType: String
}
/// Wie weit vorausgelaufen wird.
public struct Policy: Sendable {
/// Wie viel Film im Voraus geholt werden soll.
public let lookahead: Duration
/// Wie viel Platz der Vorrat höchstens belegen darf.
public let diskLimit: Int
public init(lookahead: Duration = .seconds(300), diskLimit: Int = 768 * 1_024 * 1_024) {
self.lookahead = lookahead
self.diskLimit = diskLimit
}
}
private let policy: Policy
private let session: URLSession
private let directory: URL
private let logger = Logger(subsystem: "app.halo", category: "StreamCache")
/// Woher die Schnipsel wirklich kommen.
private var upstream: URL?
private var headers: [String: String] = [:]
/// Die Liste, in der Reihenfolge des Servers.
private var segments: [String] = []
private var targetDuration: Duration = .seconds(6)
/// Was schon auf der Platte liegt — Adresse auf Datei **und Sorte**.
///
/// **Die Sorte gehört dazu.** Ein Schnipsel ist nicht zwangsläufig Bild:
/// die Untertitelspur liefert WebVTT, der Ton kann AAC sein. Wer die
/// Sorte wegwirft und später etwas behauptet, liefert dem Player einen
/// Text als Video — der gibt ihn dem falschen Parser, findet keine Box,
/// und es entsteht lautlos gar nichts.
private var stored: [String: (ort: URL, type: String)] = [:]
private var storedBytes = 0
/// Welchen Schnipsel der Player zuletzt verlangt hat.
private var lastRequested = 0
/// Bis wohin geholt wurde.
private var fetched = 0
private var prefetchTask: Task<Void, Never>?
/// Ob gerade eine Anfrage des Players offen ist.
private var isServingPlayer = false
public init(policy: Policy = Policy()) {
self.policy = policy
let configuration = URLSessionConfiguration.ephemeral
// Der Vorrat ist unser Zwischenspeicher; ein zweiter darunter
// verdoppelte nur den Platzbedarf.
configuration.urlCache = nil
configuration.httpMaximumConnectionsPerHost = 4
configuration.timeoutIntervalForRequest = 30
self.session = URLSession(configuration: configuration)
self.directory = FileManager.default.temporaryDirectory
.appending(path: "halo-stream-\(UUID().uuidString)", directoryHint: .isDirectory)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
// MARK: - Einrichten
/// Nimmt die Abspielliste entgegen und beginnt vorauszulaufen.
///
/// **Alles wird auf eine vollständige Adresse umgeschrieben.** Relative
/// Angaben gelten je nach Liste gegen eine andere Grundadresse — die
/// Untertitelspur liegt woanders als das Bild. Wer das erst beim Abholen
/// auflöst, löst es irgendwann gegen die falsche auf. Hier steht die
/// Grundadresse noch fest.
public func start(upstream url: URL, headers: [String: String] = [:]) async throws -> String {
self.headers = headers
let liste = try await load(url)
return rewrite(liste, base: url, adopting: !liste.isMaster)
}
/// Eine Liste, die der Player eine Ebene tiefer angefordert hat.
///
/// `prefetch` sagt, ob dahinter Bild liegt. Bei einer Nebenspur — also
/// Untertiteln — steht es auf `false`: sonst lüde der Vorrat Untertitel
/// statt Bild, und der Film bliebe stehen, während die Kachel meldet, es
/// sei reichlich geladen.
public func playlist(at address: String, prefetch: Bool) async -> String? {
guard let url = URL(string: address), let liste = try? await load(url) else { return nil }
return rewrite(liste, base: url, adopting: prefetch && !liste.isMaster)
}
private func rewrite(_ liste: MediaPlaylist, base: URL, adopting: Bool) -> String {
if adopting { adopt(liste, base: base) }
return liste.rewritingReferences { adresse, art in
let absolut = URL(string: adresse, relativeTo: base)?.absoluteString ?? adresse
switch art {
case .level:
return LocalStreamServer.localPlaylistPath(for: absolut, prefetch: true)
case .rendition:
return LocalStreamServer.localPlaylistPath(for: absolut, prefetch: false)
case .map, .segment:
return LocalStreamServer.localPath(for: absolut)
}
}
}
/// Übernimmt eine Liste von Schnipseln als die, der wir vorauslaufen.
private func adopt(_ liste: MediaPlaylist, base: URL) {
// **Nur bei einem echten Wechsel von vorn.** Der Player holt dieselbe
// Liste immer wieder — nach einem Sprung, beim Stufenwechsel, von
// sich aus. Jedes Mal zurückzusetzen hiesse, den Vorrat jedes Mal zu
// vergessen und von Schnipsel null neu zu zählen.
let neueQuelle = upstream != base
upstream = base
segments = liste.segments.map {
URL(string: $0, relativeTo: base)?.absoluteString ?? $0
}
if let dauer = liste.targetDuration { targetDuration = dauer }
if neueQuelle {
lastRequested = 0
fetched = 0
}
beginPrefetching()
}
private func load(_ url: URL) async throws -> MediaPlaylist {
var request = URLRequest(url: url)
for (name, wert) in headers { request.setValue(wert, forHTTPHeaderField: name) }
let (daten, _) = try await session.data(for: request)
return MediaPlaylist(String(decoding: daten, as: UTF8.self))
}
// MARK: - Ausliefern
/// Die Daten zu einer Adresse — aus dem Vorrat, sonst frisch geholt.
public func data(for address: String) async -> Segment? {
if let index = segments.firstIndex(of: address) {
lastRequested = max(lastRequested, index)
trimBehind()
}
if let eintrag = stored[address], let daten = try? Data(contentsOf: eintrag.ort) {
return Segment(data: daten, contentType: eintrag.type)
}
// **Der Player hat Vorrang.** Fehlt ihm etwas, wartet er darauf — und
// der Vorrat darf ihm dabei nicht die Leitung wegnehmen. Rechnet der
// Server gerade um, bedient er ohnehin einen Schnipsel nach dem
// anderen: zwei Anfragen gleichzeitig machen die dringende langsamer,
// nicht schneller. Genau so kam es zu „Stream abgerissen", sobald das
// Umrechnen teuer wurde.
isServingPlayer = true
defer { isServingPlayer = false }
return await fetch(address)
}
// MARK: - Vorauslaufen
private func beginPrefetching() {
prefetchTask?.cancel()
prefetchTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
let weiter = await self.prefetchNext()
if !weiter { try? await Task.sleep(for: .milliseconds(500)) }
}
}
}
/// Holt den nächsten Schnipsel, falls einer fehlt und Platz ist.
///
/// Gibt `false` zurück, wenn gerade nichts zu tun ist — dann wartet die
/// Schleife, statt zu drehen.
private func prefetchNext() async -> Bool {
// Solange der Player wartet, wird nicht vorgeladen.
guard !isServingPlayer else { return false }
// **Ist der Player vorausgesprungen, dort weitermachen.**
//
// Sonst holt der Vorrat brav weiter Schnipsel hinter der
// Abspielstelle — und der eine, auf den gerade gewartet wird, kommt
// als letzter. Das ist genau der Fall, in dem es nach einem Sprung
// lange lädt, obwohl vorn alles bereitliegt.
if lastRequested > fetched { fetched = lastRequested }
guard fetched < segments.count else { return false }
// Der Vorsprung in Schnipseln, aus dem Vorsprung in Sekunden.
let vorsprung = Int((policy.lookahead.seconds / max(targetDuration.seconds, 1)).rounded())
guard fetched - lastRequested < vorsprung else { return false }
guard storedBytes < policy.diskLimit else { return false }
let pfad = segments[fetched]
if stored[pfad] != nil {
fetched += 1
return true
}
guard await fetch(pfad) != nil else {
// Nicht weiterzählen: beim nächsten Durchgang noch einmal
// versuchen. Ein Schnipsel, den der Server noch nicht fertig hat,
// ist kein Fehler — nur ein „gleich".
return false
}
fetched += 1
return true
}
@discardableResult
private func fetch(_ address: String) async -> Segment? {
guard let ziel = URL(string: address) else { return nil }
do {
var anfrage = URLRequest(url: ziel)
for (name, wert) in headers { anfrage.setValue(wert, forHTTPHeaderField: name) }
let (daten, antwort) = try await session.data(for: anfrage)
let http = antwort as? HTTPURLResponse
guard http?.statusCode ?? 200 < 400 else { return nil }
// **Die Sorte kommt von der Gegenstelle, nicht von uns.** Sie
// weiss, was sie geschickt hat; wir wüssten es nur aus der
// Endung, und die kann fehlen oder lügen. Erst wenn sie nichts
// sagt, raten wir anhand der Adresse.
let sorte = http?.value(forHTTPHeaderField: "Content-Type")
?? Self.guessedType(for: address)
store(daten, type: sorte, at: address)
return Segment(data: daten, contentType: sorte)
} catch {
return nil
}
}
/// Die Sorte aus der Endung — der Notnagel.
static func guessedType(for address: String) -> String {
let pfad = URL(string: address)?.path.lowercased() ?? address.lowercased()
if pfad.hasSuffix(".vtt") { return "text/vtt" }
if pfad.hasSuffix(".ts") { return "video/MP2T" }
if pfad.hasSuffix(".aac") { return "audio/aac" }
if pfad.hasSuffix(".m3u8") { return "application/vnd.apple.mpegurl" }
return "video/mp4"
}
private func store(_ daten: Data, type: String, at path: String) {
let ort = directory.appending(path: UUID().uuidString)
guard (try? daten.write(to: ort, options: .atomic)) != nil else { return }
stored[path] = (ort, type)
storedBytes += daten.count
}
/// Wirft weg, was hinter der Abspielstelle liegt.
///
/// **Nicht alles dahinter**, sondern mit Abstand: ein kurzer Rücksprung
/// ist üblich, und ihn jedes Mal neu zu holen wäre teurer als die paar
/// Megabyte.
private func trimBehind() {
let grenze = lastRequested - 12
guard grenze > 0 else { return }
for index in 0..<min(grenze, segments.count) {
let pfad = segments[index]
guard let eintrag = stored.removeValue(forKey: pfad) else { continue }
let größe = (try? FileManager.default.attributesOfItem(atPath: eintrag.ort.path))
.flatMap { $0[.size] as? Int } ?? 0
storedBytes = max(0, storedBytes - größe)
try? FileManager.default.removeItem(at: eintrag.ort)
}
}
// MARK: - Abräumen
public func teardown() {
prefetchTask?.cancel()
prefetchTask = nil
stored.removeAll()
storedBytes = 0
try? FileManager.default.removeItem(at: directory)
}
/// Wie viel Film im Voraus bereitliegt — für die Anzeige.
public var bufferedAhead: Duration {
let voraus = max(0, fetched - lastRequested)
return .seconds(Double(voraus) * targetDuration.seconds)
}
}