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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f98b995050
commit
a7cf6c5def
@@ -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.flaro", 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.flaro", 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: "flaro-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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user