Drei Gruende, und der erste erklaert das ganze Aergernis. AVPlayer besteht nach einem Sprung darauf, erst seinen eigenen Puffer zu fuellen. Der stand auf sechzig Sekunden — richtig, solange er selbst vorladen sollte. Mit einem Speicher davor ist es das Gegenteil: springt man mitten in den Vorrat, liegt dahinter vielleicht noch eine halbe Minute, zu wenig fuer seine sechzig, und er wartet auf den Server, obwohl alles da ist. Der Abstand liegt jetzt bei uns; seiner darf klein sein. Der Vorrat holte ausserdem weiter Schnipsel hinter der Abspielstelle, wenn der Player vorausgesprungen war — und der eine, auf den gerade gewartet wurde, kam als letzter. Er macht jetzt dort weiter, wo gesprungen wurde. Und dieselbe Liste noch einmal zu holen setzte den Vorrat zurueck. Der Player holt sie staendig: nach einem Sprung, beim Stufenwechsel, von sich aus. Zurueckgesetzt wird nur noch bei einem echten Wechsel der Quelle. Dazu ein latenter Fehler, der beim Umpacken ein Schwarzbild gegeben haette: in EXT-X-MAP steckt die Adresse des Kopfstuecks einer fragmentierten MP4, ohne das kein einziger Schnipsel zu dekodieren ist. Sie blieb stehen und zeigte damit relativ auf den lokalen Server. Der alte Test hielt genau dieses Verhalten fest — er hat mitgeaendert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
260 lines
10 KiB
Swift
260 lines
10 KiB
Swift
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<Void, Never>?
|
|
|
|
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..<min(grenze, segments.count) {
|
|
let pfad = segments[index]
|
|
guard let ort = stored.removeValue(forKey: pfad) else { continue }
|
|
let größe = (try? FileManager.default.attributesOfItem(atPath: ort.path))
|
|
.flatMap { $0[.size] as? Int } ?? 0
|
|
storedBytes = max(0, storedBytes - größe)
|
|
try? FileManager.default.removeItem(at: 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)
|
|
}
|
|
}
|