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:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user