Files
Flaro/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/StreamCache.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

245 lines
9.6 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 {
upstream = url
segments = liste.segments
if let dauer = liste.targetDuration { targetDuration = dauer }
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 {
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)
}
}