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>
212 lines
8.0 KiB
Swift
212 lines
8.0 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?
|
|
/// 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.
|
|
/// Ist es eine Liste von Stufen, wird nichts vorgeholt: dort stehen keine
|
|
/// Schnipsel, sondern weitere Listen.
|
|
public func start(upstream url: URL, headers: [String: String] = [:]) async throws -> String {
|
|
upstream = url
|
|
|
|
var request = URLRequest(url: url)
|
|
for (name, wert) in headers { request.setValue(wert, forHTTPHeaderField: name) }
|
|
let (daten, _) = try await session.data(for: request)
|
|
|
|
let liste = MediaPlaylist(String(decoding: daten, as: UTF8.self))
|
|
guard !liste.isMaster else { return liste.rewritingReferences { $0 } }
|
|
|
|
segments = liste.segments
|
|
if let dauer = liste.targetDuration { targetDuration = dauer }
|
|
beginPrefetching()
|
|
|
|
return liste.rewritingReferences { LocalStreamServer.localPath(for: $0) }
|
|
}
|
|
|
|
// 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 {
|
|
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)
|
|
}
|
|
}
|