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[.. Bool { lock.withLock { guard !used else { return false } used = true return true } } }