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 { /// 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.flixy", 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 — Pfad auf Datei. private var stored: [String: URL] = [:] 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? 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: "flixy-stream-\(UUID().uuidString)", directoryHint: .isDirectory) try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) } // MARK: - Einrichten /// Nimmt die Abspielliste entgegen und beginnt vorauszulaufen. /// /// Gibt die umgeschriebene Liste zurück — die, die der Player bekommt. /// /// **Zwei Ebenen, und beide müssen durch uns.** Bei „Auto" bestellen wir /// eine Liste von **Stufen**; die Schnipsel stehen eine Ebene tiefer. Wer /// hier aussteigt, reicht relative Adressen durch, die danach auf den /// lokalen Server zeigen — und dort gibt es sie nicht. Genau daran ist /// der erste Anlauf gescheitert: es wurde nichts vorgeholt, und beinahe /// wäre auch nichts mehr gelaufen. /// /// Also wird auch die Stufenliste umgeschrieben. Holt der Player dann /// eine Stufe, kommt er wieder bei uns an, und **dort** beginnt das /// Vorauslaufen — bei der Stufe, für die er sich entschieden hat. public func start(upstream url: URL, headers: [String: String] = [:]) async throws -> String { self.headers = headers let (liste, _) = try await load(url) guard !liste.isMaster else { return liste.rewritingReferences { eintrag in let absolut = URL(string: eintrag, relativeTo: url)?.absoluteString ?? eintrag return LocalStreamServer.localPlaylistPath(for: absolut) } } return adopt(liste, from: url) } /// Eine Stufe, die der Player angefordert hat. /// /// Ab hier stehen die Schnipsel fest, und der Vorrat beginnt zu laufen. public func playlist(at address: String) async -> String? { guard let url = URL(string: address), let (liste, _) = try? await load(url) else { return nil } guard !liste.isMaster else { return liste.rewritingReferences { $0 } } return adopt(liste, from: url) } /// Übernimmt eine Liste von Schnipseln als die, der wir vorauslaufen. private func adopt(_ liste: MediaPlaylist, from url: URL) -> String { // **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 != url upstream = url segments = liste.segments if let dauer = liste.targetDuration { targetDuration = dauer } if neueQuelle { lastRequested = 0 fetched = 0 } beginPrefetching() return liste.rewritingReferences { LocalStreamServer.localPath(for: $0) } } private func load(_ url: URL) async throws -> (MediaPlaylist, Data) { 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)), daten) } // MARK: - Ausliefern /// Die Daten zu einem Pfad — aus dem Vorrat, sonst frisch geholt. public func data(for path: String) async -> Data? { if let index = segments.firstIndex(of: path) { // Woran der Player gerade ist. Daran hängt, wie weit wir // vorauslaufen — und was wir wegwerfen dürfen. lastRequested = max(lastRequested, index) trimBehind() } if let ort = stored[path], let daten = try? Data(contentsOf: ort) { return daten } return await fetch(path) } // 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 { // **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(_ path: String) async -> Data? { guard let upstream, let ziel = URL(string: path, relativeTo: upstream)?.absoluteURL else { return nil } do { let (daten, antwort) = try await session.data(from: ziel) guard (antwort as? HTTPURLResponse)?.statusCode ?? 200 < 400 else { return nil } store(daten, at: path) return daten } catch { return nil } } private func store(_ daten: Data, at path: String) { let ort = directory.appending(path: UUID().uuidString) guard (try? daten.write(to: ort, options: .atomic)) != nil else { return } stored[path] = ort 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..