Files
Flaro/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift
T
ScarriffleandClaude Opus 5 bbd1c3b572 Der Vorrat steigt jetzt auch eine Ebene tiefer
Bei „Auto\" bestellen wir eine Liste von Stufen; die Schnipsel stehen eine
Ebene darunter. Der Speicher erkannte das zwar, stieg dann aber aus und
reichte die Liste mit relativen Adressen durch — die zeigten danach auf den
lokalen Server, wo es sie nicht gibt. Es wurde nichts vorgeholt, und
beinahe waere auch nichts mehr gelaufen.

Jetzt wird auch die Stufenliste umgeschrieben. Holt der Player eine Stufe,
kommt er wieder bei uns an, und dort beginnt das Vorauslaufen — bei der
Stufe, fuer die er sich entschieden hat.

Dazu eine Zeile im Qualitaetsblatt, die sagt, ob der Vorrat laeuft und wie
weit er voraus ist. Von aussen ist das nicht zu sehen: laeuft er nicht,
spielt es trotzdem, nur ohne Vorsprung — und genau so sieht auch ein
Vorrat aus, der laeuft und nichts bekommt. Ohne diese Zeile sind die beiden
Faelle nicht zu unterscheiden, und ich haette wieder geraten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 20:29:20 +02:00

226 lines
8.7 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"
/// 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.
public nonisolated static func localPlaylistPath(for absolute: String) -> String {
var teile = URLComponents()
teile.path = levelPrefix
teile.queryItems = [URLQueryItem(name: "u", value: absolute)]
return teile.string ?? levelPrefix
}
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.levelPrefix) {
guard let teile = URLComponents(string: pfadTeil),
let adresse = teile.queryItems?.first(where: { $0.name == "u" })?.value,
let inhalt = await cache.playlist(at: adresse)
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 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
}
}
}