AVPlayer entscheidet selbst, wie weit er puffert, und bei einer Abspielliste laesst Apple uns nicht dazwischen: auf eine Anfrage nach einem Schnipsel nimmt er von uns nur eine Weiterleitung an, keine Daten. Der Denkfehler war, seinen Puffer vergroessern zu wollen. Wir muessen aber gar nicht dazwischen — wir koennen die Gegenstelle sein. Ein winziger HTTP-Server auf 127.0.0.1 liefert dem Player die Abspielliste, umgeschrieben auf sich selbst, und holt die Schnipsel zuegiger von Jellyfin, als abgespielt wird. Jedes Segment, das der Player verlangt, liegt dann schon lokal. Faellt die Verbindung im Tunnel aus, merkt er es nicht. Das ist der Unterschied zwischen Puffer und Vorrat: den Puffer bestimmt Apple, den Vorrat bestimmen wir. Streng der Reihe nach, und mit begrenztem Vorsprung. Der Server erzeugt die Schnipsel gerade erst; wer Nummer 40 verlangt, waehrend er bei 12 ist, sieht fuer Jellyfin aus wie jemand, der gesprungen ist — und dann beginnt das Umrechnen von vorn, an der falschen Stelle. Der Lauscher haengt ausdruecklich an 127.0.0.1, nicht an allen Schnittstellen. Ein Server, der den Film ins Netz stellt, waere das Letzte, was eine Mediathek-App tun sollte. Kommt er nicht hoch, spielt es trotzdem — ueber die urspruengliche Adresse, ohne Vorrat. Ein fehlender Vorrat ist ein Verlust an Bequemlichkeit; keine Wiedergabe ist ein Verlust an allem. Das Lesen und Umschreiben der Abspielliste ist ein eigener Typ ohne Netz, mit sieben Tests. Eine falsch umgeschriebene Liste ergibt entweder ein schwarzes Bild oder — schlimmer — eine, die stellenweise am Speicher vorbeigeht und nur manchmal hakt. So etwas findet man im laufenden Film nicht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
197 lines
7.4 KiB
Swift
197 lines
7.4 KiB
Swift
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"
|
|
|
|
/// 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.
|
|
public nonisolated static func localPath(for original: String) -> String {
|
|
var teile = URLComponents()
|
|
teile.path = segmentPrefix
|
|
teile.queryItems = [URLQueryItem(name: "p", value: original)]
|
|
return teile.string ?? segmentPrefix
|
|
}
|
|
|
|
private let cache: StreamCache
|
|
private let logger = Logger(subsystem: "app.flixy", 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.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 original = teile.queryItems?.first(where: { $0.name == "p" })?.value
|
|
else { return finish(verbindung, status: 404, body: Data()) }
|
|
|
|
guard let daten = await cache.data(for: original) else {
|
|
return finish(verbindung, status: 504, body: Data())
|
|
}
|
|
finish(verbindung, status: 200, body: daten, type: "video/mp4")
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|